diff options
| author | soryu <soryu@soryu.co> | 2026-01-11 05:52:14 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 00:21:16 +0000 |
| commit | 87044a747b47bd83249d61a45842c7f7b2eae56d (patch) | |
| tree | ef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/components/files | |
| parent | 077820c4167c168072d217a1b01df840463a12a8 (diff) | |
| download | soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip | |
Contract system
Diffstat (limited to 'makima/frontend/src/components/files')
| -rw-r--r-- | makima/frontend/src/components/files/BodyRenderer.tsx | 376 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileDetail.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileList.tsx | 179 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/RepoSyncIndicator.tsx | 190 |
4 files changed, 597 insertions, 150 deletions
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx index cf99fde..c3f402e 100644 --- a/makima/frontend/src/components/files/BodyRenderer.tsx +++ b/makima/frontend/src/components/files/BodyRenderer.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react"; import type { BodyElement } from "../../lib/api"; import { ChartRenderer } from "../charts/ChartRenderer"; import { ElementContextMenu } from "./ElementContextMenu"; +import { copyMarkdownToClipboard } from "../../lib/markdown"; interface BodyRendererProps { elements: BodyElement[]; @@ -42,6 +43,15 @@ export function BodyRenderer({ elementIndex: number; selectedText?: string; } | null>(null); + const [copiedMarkdown, setCopiedMarkdown] = useState(false); + + const handleCopyMarkdown = async () => { + const success = await copyMarkdownToClipboard(elements); + if (success) { + setCopiedMarkdown(true); + setTimeout(() => setCopiedMarkdown(false), 2000); + } + }; const handleContextMenu = (index: number) => (e: React.MouseEvent) => { e.preventDefault(); @@ -104,6 +114,31 @@ export function BodyRenderer({ return ( <div className="space-y-1"> + {/* Markdown Export Toolbar */} + <div className="flex justify-end mb-2"> + <button + onClick={handleCopyMarkdown} + className="flex items-center gap-1 px-2 py-1 text-[10px] font-mono text-[#555] hover:text-[#75aafc] transition-colors" + title="Copy content as markdown" + > + {copiedMarkdown ? ( + <> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <polyline points="20 6 9 17 4 12" /> + </svg> + Copied! + </> + ) : ( + <> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" /> + <rect x="8" y="2" width="8" height="4" rx="1" ry="1" /> + </svg> + Copy as Markdown + </> + )} + </button> + </div> {elements.map((element, index) => ( <div key={index} @@ -250,6 +285,20 @@ function BodyElementRenderer({ caption={element.caption} /> ); + case "markdown": + return ( + <MarkdownElement + content={element.content} + onUpdate={ + onUpdate + ? (content) => onUpdate({ ...element, content }) + : undefined + } + onEditingChange={onEditingChange} + hasPendingRemoteUpdate={hasPendingRemoteUpdate} + onOverwrite={onOverwrite} + /> + ); default: return null; } @@ -621,3 +670,330 @@ function ListElement({ </ListTag> ); } + +/** + * Simple inline markdown renderer. + * Renders basic markdown syntax to HTML elements. + */ +function renderMarkdown(content: string): React.ReactNode { + const lines = content.split('\n'); + const elements: React.ReactNode[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Code blocks + if (line.startsWith('```')) { + const lang = line.slice(3).trim(); + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + i++; // skip closing ``` + elements.push( + <div key={elements.length} className="relative my-2"> + {lang && ( + <div className="absolute top-0 right-0 px-2 py-0.5 font-mono text-[10px] text-[#555] bg-[#1a1a1a] border-b border-l border-[#333]"> + {lang} + </div> + )} + <pre className="bg-[#0d0d0d] border border-[#333] p-4 overflow-x-auto"> + <code className="font-mono text-sm text-[#9bc3ff] whitespace-pre"> + {codeLines.join('\n')} + </code> + </pre> + </div> + ); + continue; + } + + // Headings + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + const level = headingMatch[1].length; + const text = headingMatch[2]; + const HeadingTag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + const sizeClasses: Record<number, string> = { + 1: "text-2xl font-bold", + 2: "text-xl font-bold", + 3: "text-lg font-semibold", + 4: "text-base font-semibold", + 5: "text-sm font-semibold", + 6: "text-xs font-semibold", + }; + elements.push( + <HeadingTag key={elements.length} className={`font-mono text-[#9bc3ff] ${sizeClasses[level]} my-2`}> + {renderInlineMarkdown(text)} + </HeadingTag> + ); + i++; + continue; + } + + // Unordered lists + if (line.match(/^[-*]\s+/)) { + const items: string[] = []; + while (i < lines.length && lines[i].match(/^[-*]\s+/)) { + items.push(lines[i].replace(/^[-*]\s+/, '')); + i++; + } + elements.push( + <ul key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 list-disc my-2"> + {items.map((item, idx) => ( + <li key={idx} className="pl-1">{renderInlineMarkdown(item)}</li> + ))} + </ul> + ); + continue; + } + + // Ordered lists + if (line.match(/^\d+\.\s+/)) { + const items: string[] = []; + while (i < lines.length && lines[i].match(/^\d+\.\s+/)) { + items.push(lines[i].replace(/^\d+\.\s+/, '')); + i++; + } + elements.push( + <ol key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 list-decimal my-2"> + {items.map((item, idx) => ( + <li key={idx} className="pl-1">{renderInlineMarkdown(item)}</li> + ))} + </ol> + ); + continue; + } + + // Empty lines + if (line.trim() === '') { + i++; + continue; + } + + // Regular paragraphs + elements.push( + <p key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed my-2"> + {renderInlineMarkdown(line)} + </p> + ); + i++; + } + + return <>{elements}</>; +} + +/** + * Render inline markdown (bold, italic, code, links). + */ +function renderInlineMarkdown(text: string): React.ReactNode { + // Process inline elements: **bold**, *italic*, `code`, [link](url) + const parts: React.ReactNode[] = []; + let remaining = text; + let keyCounter = 0; + + while (remaining.length > 0) { + // Check for inline code + const codeMatch = remaining.match(/^`([^`]+)`/); + if (codeMatch) { + parts.push( + <code key={keyCounter++} className="bg-[#1a1a1a] px-1 py-0.5 text-[#9bc3ff] border border-[#333] text-xs"> + {codeMatch[1]} + </code> + ); + remaining = remaining.slice(codeMatch[0].length); + continue; + } + + // Check for bold + const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/); + if (boldMatch) { + parts.push(<strong key={keyCounter++} className="font-bold">{boldMatch[1]}</strong>); + remaining = remaining.slice(boldMatch[0].length); + continue; + } + + // Check for italic + const italicMatch = remaining.match(/^\*([^*]+)\*/); + if (italicMatch) { + parts.push(<em key={keyCounter++} className="italic">{italicMatch[1]}</em>); + remaining = remaining.slice(italicMatch[0].length); + continue; + } + + // Check for links + const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/); + if (linkMatch) { + parts.push( + <a key={keyCounter++} href={linkMatch[2]} className="text-[#75aafc] hover:underline" target="_blank" rel="noopener noreferrer"> + {linkMatch[1]} + </a> + ); + remaining = remaining.slice(linkMatch[0].length); + continue; + } + + // Find next special character or end + const nextSpecial = remaining.search(/[`*\[]/); + if (nextSpecial === -1) { + parts.push(remaining); + break; + } else if (nextSpecial === 0) { + // Special char at start but didn't match a pattern - treat as text + parts.push(remaining[0]); + remaining = remaining.slice(1); + } else { + parts.push(remaining.slice(0, nextSpecial)); + remaining = remaining.slice(nextSpecial); + } + } + + return <>{parts}</>; +} + +function MarkdownElement({ + content, + onUpdate, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, +}: { + content: string; + onUpdate?: (content: string) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [editContent, setEditContent] = useState(content); + const textareaRef = useRef<HTMLTextAreaElement>(null); + + useEffect(() => { + if (!isEditing) { + setEditContent(content); + } + }, [content, isEditing]); + + useEffect(() => { + if (isEditing && textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = textareaRef.current.scrollHeight + "px"; + } + }, [isEditing]); + + const startEditing = () => { + setIsEditing(true); + onEditingChange?.(true); + }; + + const stopEditing = () => { + setIsEditing(false); + onEditingChange?.(false); + }; + + const handleSave = () => { + if (hasPendingRemoteUpdate) return; + if (onUpdate && editContent !== content) { + onUpdate(editContent); + } + stopEditing(); + }; + + const handleOverwrite = () => { + if (onUpdate && editContent !== content) { + onUpdate(editContent); + } + onOverwrite?.(); + stopEditing(); + }; + + const handleCancel = () => { + setEditContent(content); + stopEditing(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + handleCancel(); + } + if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !hasPendingRemoteUpdate) { + handleSave(); + } + }; + + const handleBlur = () => { + if (!hasPendingRemoteUpdate) { + handleSave(); + } + }; + + const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => { + setEditContent(e.target.value); + e.target.style.height = "auto"; + e.target.style.height = e.target.scrollHeight + "px"; + }; + + if (isEditing && onUpdate) { + return ( + <div className="relative"> + <div className="text-[10px] text-[#555] font-mono mb-1 flex items-center gap-2"> + <span className="text-[#75aafc]">Editing Markdown</span> + </div> + <textarea + ref={textareaRef} + value={editContent} + onChange={handleInput} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + className="font-mono text-sm text-white/80 leading-relaxed w-full bg-[#0d0d0d] border border-[#3f6fb3] outline-none p-3 resize-none min-h-[120px]" + placeholder="Enter markdown content..." + /> + {hasPendingRemoteUpdate ? ( + <div className="flex items-center gap-2 mt-2"> + <span className="text-yellow-500 text-xs font-mono">Remote update pending</span> + <button + onClick={handleOverwrite} + onMouseDown={(e) => e.preventDefault()} + className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors" + > + Overwrite + </button> + <button + onClick={handleCancel} + onMouseDown={(e) => e.preventDefault()} + className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors" + > + Cancel + </button> + </div> + ) : ( + <div className="text-[10px] text-[#555] font-mono mt-1"> + Ctrl+Enter to save, Esc to cancel + </div> + )} + </div> + ); + } + + return ( + <div + className={`border border-[#333] bg-[#0a0a0a] p-4 rounded ${onUpdate ? "cursor-text hover:border-[#3f6fb3] transition-colors" : ""}`} + onClick={() => onUpdate && startEditing()} + > + <div className="text-[10px] text-[#555] font-mono mb-2 flex items-center gap-1"> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> + <path d="M14 2v6h6" /> + <path d="M16 13H8" /> + <path d="M16 17H8" /> + <path d="M10 9H8" /> + </svg> + <span>Markdown</span> + </div> + {renderMarkdown(content)} + </div> + ); +} diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index 60458e9..a030c57 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -87,6 +87,8 @@ export function FileDetail({ return element.title || `${element.chartType} chart`; case "image": return element.alt || element.caption || "Image"; + case "markdown": + return element.content.slice(0, 50) + (element.content.length > 50 ? "..." : ""); default: return "Element"; } diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx index c537846..188a1df 100644 --- a/makima/frontend/src/components/files/FileList.tsx +++ b/makima/frontend/src/components/files/FileList.tsx @@ -1,5 +1,7 @@ import { useRef } from "react"; -import type { FileSummary, BodyElement } from "../../lib/api"; +import { useNavigate } from "react-router"; +import type { FileSummary, BodyElement, ContractPhase } from "../../lib/api"; +import { markdownToBody } from "../../lib/markdown"; interface FileListProps { files: FileSummary[]; @@ -10,153 +12,6 @@ interface FileListProps { onUploadMarkdown?: (name: string, body: BodyElement[]) => void; } -/** - * Parse markdown text into BodyElements. - * Converts image embeds to links instead of images. - */ -function parseMarkdown(markdown: string): BodyElement[] { - const elements: BodyElement[] = []; - const lines = markdown.split('\n'); - let currentParagraph: string[] = []; - let inCodeBlock = false; - let codeBlockLanguage: string | undefined; - let codeBlockContent: string[] = []; - let currentList: { ordered: boolean; items: string[] } | null = null; - - const flushParagraph = () => { - if (currentParagraph.length > 0) { - const text = currentParagraph.join('\n').trim(); - if (text) { - elements.push({ type: "paragraph", text }); - } - currentParagraph = []; - } - }; - - const flushCodeBlock = () => { - if (codeBlockContent.length > 0 || inCodeBlock) { - elements.push({ - type: "code", - language: codeBlockLanguage || undefined, - content: codeBlockContent.join('\n'), - }); - codeBlockContent = []; - codeBlockLanguage = undefined; - } - }; - - const flushList = () => { - if (currentList && currentList.items.length > 0) { - elements.push({ - type: "list", - ordered: currentList.ordered, - items: currentList.items, - }); - currentList = null; - } - }; - - // Convert image syntax  to link syntax [alt](url) or [image](url) - const convertImagesToLinks = (text: string): string => { - return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => { - const linkText = alt || 'image'; - return `[${linkText}](${url})`; - }); - }; - - for (const rawLine of lines) { - // Check for code block fence (``` or ~~~) - const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/); - if (codeFenceMatch) { - if (!inCodeBlock) { - // Starting a code block - flushParagraph(); - flushList(); - inCodeBlock = true; - codeBlockLanguage = codeFenceMatch[2] || undefined; - codeBlockContent = []; - } else { - // Ending a code block - flushCodeBlock(); - inCodeBlock = false; - } - continue; - } - - // If inside a code block, add line as-is - if (inCodeBlock) { - codeBlockContent.push(rawLine); - continue; - } - - // Convert images to links in all lines - const line = convertImagesToLinks(rawLine); - - // Check for headings - const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); - if (headingMatch) { - flushParagraph(); - flushList(); - const level = headingMatch[1].length; - const text = headingMatch[2].trim(); - elements.push({ type: "heading", level, text }); - continue; - } - - // Check for unordered list items (-, *, +) - const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/); - if (unorderedMatch) { - flushParagraph(); - const itemText = unorderedMatch[1].trim(); - if (currentList && currentList.ordered) { - // Switch from ordered to unordered - flushList(); - } - if (!currentList) { - currentList = { ordered: false, items: [] }; - } - currentList.items.push(itemText); - continue; - } - - // Check for ordered list items (1. 2. etc) - const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/); - if (orderedMatch) { - flushParagraph(); - const itemText = orderedMatch[1].trim(); - if (currentList && !currentList.ordered) { - // Switch from unordered to ordered - flushList(); - } - if (!currentList) { - currentList = { ordered: true, items: [] }; - } - currentList.items.push(itemText); - continue; - } - - // Empty line - flush everything - if (line.trim() === '') { - flushParagraph(); - flushList(); - continue; - } - - // Regular text - flush list first, then add to paragraph - flushList(); - currentParagraph.push(line); - } - - // Flush any remaining content - if (inCodeBlock) { - flushCodeBlock(); - } - flushParagraph(); - flushList(); - - return elements; -} - function formatDuration(seconds: number | null): string { if (seconds === null) return "-"; const mins = Math.floor(seconds / 60); @@ -175,6 +30,14 @@ function formatDate(dateStr: string): string { }); } +const phaseColors: Record<ContractPhase, string> = { + research: "bg-purple-500/20 text-purple-400 border-purple-400/30", + specify: "bg-blue-500/20 text-blue-400 border-blue-400/30", + plan: "bg-cyan-500/20 text-cyan-400 border-cyan-400/30", + execute: "bg-green-500/20 text-green-400 border-green-400/30", + review: "bg-yellow-500/20 text-yellow-400 border-yellow-400/30", +}; + export function FileList({ files, loading, @@ -183,6 +46,7 @@ export function FileList({ onCreate, onUploadMarkdown, }: FileListProps) { + const navigate = useNavigate(); const fileInputRef = useRef<HTMLInputElement>(null); const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { @@ -193,7 +57,7 @@ export function FileList({ reader.onload = (e) => { const content = e.target?.result as string; if (content) { - const body = parseMarkdown(content); + const body = markdownToBody(content); // Use filename without extension as the name const name = file.name.replace(/\.md$/i, '') || 'Imported Document'; onUploadMarkdown(name, body); @@ -273,10 +137,25 @@ export function FileList({ {file.description} </p> )} - <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]"> + <div className="flex items-center gap-4 font-mono text-[10px] text-[#75aafc]"> <span>{file.transcriptCount} segments</span> <span>{formatDuration(file.duration)}</span> <span>{formatDate(file.createdAt)}</span> + {file.contractId && file.contractName && ( + <button + onClick={(e) => { + e.stopPropagation(); + navigate(`/contracts/${file.contractId}`); + }} + className={`px-2 py-0.5 text-[9px] font-mono uppercase border rounded ${ + file.contractPhase ? phaseColors[file.contractPhase] : "bg-[#21262d] text-[#8b949e] border-[#30363d]" + } hover:opacity-80 transition-opacity`} + title={`View contract: ${file.contractName}`} + > + {file.contractName} + {file.contractPhase && ` ยท ${file.contractPhase}`} + </button> + )} </div> </button> <button diff --git a/makima/frontend/src/components/files/RepoSyncIndicator.tsx b/makima/frontend/src/components/files/RepoSyncIndicator.tsx new file mode 100644 index 0000000..82d79f7 --- /dev/null +++ b/makima/frontend/src/components/files/RepoSyncIndicator.tsx @@ -0,0 +1,190 @@ +import { useState, useCallback } from "react"; +import { syncFileFromRepo } from "../../lib/api"; + +interface RepoSyncIndicatorProps { + fileId: string; + repoFilePath: string | null | undefined; + repoSyncStatus: string | null | undefined; + repoSyncedAt: string | null | undefined; + onSyncComplete?: () => void; + /** Callback to push file content to repo (creates a task) */ + onPushToRepo?: () => void; + /** Whether a push operation is in progress */ + isPushing?: boolean; +} + +/** + * Shows repository file link status and provides sync functionality. + * Displays the linked file path and allows updating from the repo via daemon, + * or pushing local changes back to the repo. + */ +export function RepoSyncIndicator({ + fileId, + repoFilePath, + repoSyncStatus, + repoSyncedAt, + onSyncComplete, + onPushToRepo, + isPushing = false, +}: RepoSyncIndicatorProps) { + const [isSyncing, setIsSyncing] = useState(false); + const [error, setError] = useState<string | null>(null); + + const handleSync = useCallback(async () => { + setIsSyncing(true); + setError(null); + try { + await syncFileFromRepo(fileId); + // The actual update happens via WebSocket notification + // Give a brief delay then notify parent + setTimeout(() => { + onSyncComplete?.(); + }, 500); + } catch (err) { + setError(err instanceof Error ? err.message : "Sync failed"); + } finally { + setIsSyncing(false); + } + }, [fileId, onSyncComplete]); + + // Don't render if no repo file path is set + if (!repoFilePath) { + return null; + } + + const isActuallySyncing = isSyncing || repoSyncStatus === "syncing"; + const isSynced = repoSyncStatus === "synced"; + const isModified = repoSyncStatus === "modified"; + + // Format the synced timestamp + const syncedAtFormatted = repoSyncedAt + ? new Date(repoSyncedAt).toLocaleString() + : null; + + return ( + <div className="flex items-center gap-2 text-xs font-mono"> + {/* File path icon and link */} + <div className="flex items-center gap-1 text-[#555]"> + <svg + width="12" + height="12" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + className="flex-shrink-0" + > + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> + <polyline points="14 2 14 8 20 8" /> + </svg> + <span className="text-[#75aafc]" title={`Linked to repository file: ${repoFilePath}`}> + {repoFilePath} + </span> + </div> + + {/* Status indicator */} + {isSynced && ( + <span + className="text-green-500 flex items-center gap-1" + title={syncedAtFormatted ? `Last synced: ${syncedAtFormatted}` : "Synced"} + > + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> + <polyline points="20 6 9 17 4 12" /> + </svg> + </span> + )} + {isModified && ( + <span className="text-yellow-500" title="File modified, may need sync"> + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> + <circle cx="12" cy="12" r="10" /> + <line x1="12" y1="8" x2="12" y2="12" /> + <line x1="12" y1="16" x2="12.01" y2="16" /> + </svg> + </span> + )} + + {/* Pull from repo button */} + <button + onClick={handleSync} + disabled={isActuallySyncing || isPushing} + className={`flex items-center gap-1 px-1.5 py-0.5 rounded transition-colors ${ + isActuallySyncing || isPushing + ? "text-[#555] cursor-wait" + : "text-[#555] hover:text-[#75aafc] hover:bg-[rgba(117,170,252,0.1)]" + }`} + title="Pull latest from repository" + > + {isActuallySyncing ? ( + <> + <svg + width="10" + height="10" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + className="animate-spin" + > + <circle cx="12" cy="12" r="10" strokeDasharray="32" strokeDashoffset="8" /> + </svg> + <span>Pulling...</span> + </> + ) : ( + <> + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M12 19V5" /> + <path d="M5 12l7-7 7 7" /> + </svg> + <span>Pull</span> + </> + )} + </button> + + {/* Push to repo button */} + {onPushToRepo && ( + <button + onClick={onPushToRepo} + disabled={isActuallySyncing || isPushing} + className={`flex items-center gap-1 px-1.5 py-0.5 rounded transition-colors ${ + isPushing + ? "text-[#555] cursor-wait" + : "text-[#555] hover:text-green-500 hover:bg-[rgba(34,197,94,0.1)]" + }`} + title="Push changes to repository (creates a task)" + > + {isPushing ? ( + <> + <svg + width="10" + height="10" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + className="animate-spin" + > + <circle cx="12" cy="12" r="10" strokeDasharray="32" strokeDashoffset="8" /> + </svg> + <span>Pushing...</span> + </> + ) : ( + <> + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M12 5v14" /> + <path d="M19 12l-7 7-7-7" /> + </svg> + <span>Push</span> + </> + )} + </button> + )} + + {/* Error message */} + {error && ( + <span className="text-red-500 text-[10px]" title={error}> + Failed + </span> + )} + </div> + ); +} |
