From 87044a747b47bd83249d61a45842c7f7b2eae56d Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Sun, 11 Jan 2026 05:52:14 +0000
Subject:  Contract system

---
 .../src/components/files/RepoSyncIndicator.tsx     | 190 +++++++++++++++++++++
 1 file changed, 190 insertions(+)
 create mode 100644 makima/frontend/src/components/files/RepoSyncIndicator.tsx

(limited to 'makima/frontend/src/components/files/RepoSyncIndicator.tsx')

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>
+  );
+}
-- 
cgit v1.2.3

