summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/files
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-11 05:52:14 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:21:16 +0000
commit87044a747b47bd83249d61a45842c7f7b2eae56d (patch)
treeef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/components/files
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-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.tsx376
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx2
-rw-r--r--makima/frontend/src/components/files/FileList.tsx179
-rw-r--r--makima/frontend/src/components/files/RepoSyncIndicator.tsx190
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 ![alt](url) 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>
+ );
+}