diff --git a/apps/inkcaliber-desk/src-tauri/src/lib.rs b/apps/inkcaliber-desk/src-tauri/src/lib.rs index aa18a96..5a51525 100644 --- a/apps/inkcaliber-desk/src-tauri/src/lib.rs +++ b/apps/inkcaliber-desk/src-tauri/src/lib.rs @@ -1,7 +1,251 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::process::Command; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SyncRequest { + access_token: String, + repo_name: String, + item_path: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SyncResponse { + ok: bool, + message: String, +} + +#[derive(Debug, Deserialize)] +struct GitHubRepoResponse { + clone_url: String, +} + +#[derive(Debug, Deserialize)] +struct GitHubUserResponse { + login: String, +} + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn sync_to_github(app: tauri::AppHandle, request: SyncRequest) -> Result { + let docs_dir = app + .path() + .document_dir() + .map_err(|e| format!("Could not resolve document directory: {e}"))?; + + let inkcaliber_root = docs_dir.join("InkCaliber"); + std::fs::create_dir_all(&inkcaliber_root) + .map_err(|e| format!("Could not create InkCaliber directory: {e}"))?; + + let user = github_user(&request.access_token)?; + let remote_url = ensure_private_repo(&request.access_token, &user.login, &request.repo_name)?; + + ensure_git_repo(&inkcaliber_root)?; + ensure_origin_remote(&inkcaliber_root, &remote_url)?; + + run_git(&inkcaliber_root, &["add", "."], None, None)?; + + let commit_message = format!("sync: {}", request.item_path); + let commit_result = run_git( + &inkcaliber_root, + &["commit", "-m", &commit_message], + None, + Some(&[1]), + ); + + if commit_result.is_err() { + run_git( + &inkcaliber_root, + &["commit", "--allow-empty", "-m", &commit_message], + None, + None, + )?; + } + + let owner_repo_ref = format!("{}/{}", user.login, request.repo_name); + run_git( + &inkcaliber_root, + &["push", "-u", "origin", "HEAD:main"], + Some(&request.access_token), + None, + ) + .or_else(|_| { + run_git( + &inkcaliber_root, + &["push", "-u", "origin", "HEAD:master"], + Some(&request.access_token), + None, + ) + })?; + + Ok(SyncResponse { + ok: true, + message: format!("Synced {} to private repo {}", request.item_path, owner_repo_ref), + }) +} + +fn github_user(token: &str) -> Result { + let output = run_curl( + "GET", + "https://api.github.com/user", + token, + None, + Some(&[200]), + )?; + + serde_json::from_str::(&output) + .map_err(|e| format!("Could not parse GitHub user response: {e}")) +} + +fn ensure_private_repo(token: &str, owner: &str, repo_name: &str) -> Result { + let lookup_url = format!("https://api.github.com/repos/{owner}/{repo_name}"); + let existing = run_curl("GET", &lookup_url, token, None, Some(&[200, 404]))?; + + if !existing.trim().is_empty() { + if let Ok(repo) = serde_json::from_str::(&existing) { + return Ok(repo.clone_url); + } + } + + let payload = serde_json::json!({ + "name": repo_name, + "private": true, + "auto_init": false + }) + .to_string(); + + let created = run_curl( + "POST", + "https://api.github.com/user/repos", + token, + Some(&payload), + Some(&[201]), + )?; + + let repo = serde_json::from_str::(&created) + .map_err(|e| format!("Could not parse GitHub create repo response: {e}"))?; + + Ok(repo.clone_url) +} + +fn ensure_git_repo(root: &PathBuf) -> Result<(), String> { + if !root.join(".git").exists() { + run_git(root, &["init", "-b", "main"], None, None)?; + run_git(root, &["config", "user.name", "InkCaliber Sync"], None, None)?; + run_git(root, &["config", "user.email", "sync@inkcaliber.local"], None, None)?; + } + Ok(()) +} + +fn ensure_origin_remote(root: &PathBuf, remote_url: &str) -> Result<(), String> { + let remotes = run_git(root, &["remote"], None, None)?; + if remotes.lines().any(|line| line.trim() == "origin") { + run_git(root, &["remote", "set-url", "origin", remote_url], None, None)?; + } else { + run_git(root, &["remote", "add", "origin", remote_url], None, None)?; + } + Ok(()) +} + +fn run_git( + root: &PathBuf, + args: &[&str], + token: Option<&str>, + allow_exit_codes: Option<&[i32]>, +) -> Result { + let mut command = Command::new("git"); + command.current_dir(root); + + if let Some(t) = token { + command.arg("-c"); + command.arg(format!("http.extraHeader=Authorization: Bearer {t}")); + } + + for arg in args { + command.arg(arg); + } + + let output = command + .output() + .map_err(|e| format!("Failed to run git {:?}: {e}", args))?; + + let code = output.status.code().unwrap_or(1); + let allowed = allow_exit_codes + .map(|codes| codes.contains(&code)) + .unwrap_or(false); + if !output.status.success() && !allowed { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(format!( + "git {:?} failed (code {}): {} {}", + args, + code, + stdout.trim(), + stderr.trim() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn run_curl( + method: &str, + url: &str, + token: &str, + body: Option<&str>, + allowed_http_codes: Option<&[i32]>, +) -> Result { + let mut command = Command::new("curl"); + command.arg("-sS"); + command.arg("-X").arg(method); + command.arg("-H").arg(format!("Authorization: Bearer {token}")); + command.arg("-H").arg("Accept: application/vnd.github+json"); + command.arg("-H").arg("User-Agent: inkcaliber-desk"); + command.arg("-H").arg("Content-Type: application/json"); + + if let Some(payload) = body { + command.arg("-d").arg(payload); + } + + command.arg("-w").arg("\n__HTTP_STATUS__:%{http_code}"); + command.arg(url); + + let output = command + .output() + .map_err(|e| format!("Failed to run curl for {url}: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("curl failed: {}", stderr.trim())); + } + + let combined = String::from_utf8_lossy(&output.stdout).to_string(); + let marker = "__HTTP_STATUS__:"; + let marker_index = combined + .rfind(marker) + .ok_or_else(|| "Could not parse HTTP status from curl output".to_string())?; + + let (body_text, status_text) = combined.split_at(marker_index); + let status_code = status_text + .replace(marker, "") + .trim() + .parse::() + .map_err(|e| format!("Invalid HTTP status in curl output: {e}"))?; + + let allowed = allowed_http_codes + .map(|codes| codes.contains(&status_code)) + .unwrap_or(status_code >= 200 && status_code < 300); + + if !allowed { + return Err(format!( + "GitHub API request failed with HTTP {}: {}", + status_code, + body_text.trim() + )); + } + + Ok(body_text.trim().to_string()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -10,7 +254,7 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![sync_to_github]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/apps/inkcaliber-desk/src/pages/chat/active.tsx b/apps/inkcaliber-desk/src/pages/chat/active.tsx index e7f310d..9b6da56 100644 --- a/apps/inkcaliber-desk/src/pages/chat/active.tsx +++ b/apps/inkcaliber-desk/src/pages/chat/active.tsx @@ -12,6 +12,7 @@ import '@mantine/spotlight/styles.css'; import { Shell } from "../../components/shell"; import { getStoredTheme } from "../../theme"; import { AIProvider, createAIService, getAPIKey, Message as AIMessage } from "../../services/ai-service"; +import { syncPathToGitHub } from "../../services/github-sync"; interface Message { role: "user" | "assistant"; @@ -464,6 +465,7 @@ export default function ActiveChat() { setAiGenerating(true); try { const currentPrompt = systemPrompts.find(p => p.id === chatData.systemPrompt); + const aiResponse = await getAIResponse( userMessage.content, chatData.messages, @@ -615,6 +617,24 @@ export default function ActiveChat() { } }; + const handleGitHubSync = async () => { + const targetName = fileNameRef.current || originalFileNameRef.current; + if (!targetName || !provider) { + alert("Please create or open a chat before syncing."); + return; + } + + try { + setSyncStatus("syncing"); + await syncPathToGitHub(`chat/${provider}/${targetName}.json`); + setSyncStatus("saved"); + } catch (error: any) { + console.error("GitHub sync failed", error); + setSyncStatus("error"); + alert(`GitHub sync failed: ${error}`); + } + }; + const currentPrompt = systemPrompts.find(p => p.id === chatData.systemPrompt); return ( @@ -627,7 +647,7 @@ export default function ActiveChat() { - + diff --git a/apps/inkcaliber-desk/src/pages/diagrams/active.tsx b/apps/inkcaliber-desk/src/pages/diagrams/active.tsx index ef6e498..83f5042 100644 --- a/apps/inkcaliber-desk/src/pages/diagrams/active.tsx +++ b/apps/inkcaliber-desk/src/pages/diagrams/active.tsx @@ -20,6 +20,7 @@ import { } from "@hugeicons/core-free-icons"; import { Shell } from "../../components/shell"; import { buildDiffEntries, mergeElements } from "./merge-utils"; +import { syncPathToGitHub } from "../../services/github-sync"; type SyncStatus = "saved" | "syncing" | "error" | "loading"; @@ -267,6 +268,23 @@ export default function ActiveSession() { setDiffModalOpen(false); }; + const handleGitHubSync = async () => { + if (!sessionFile) { + alert("Please create or open a diagram before syncing."); + return; + } + + try { + setSyncStatus("syncing"); + await syncPathToGitHub(`diagrams/${sessionFile}/${currentBranch}.excalidraw`); + setSyncStatus("saved"); + } catch (error: any) { + console.error("GitHub sync failed", error); + setSyncStatus("error"); + alert(`GitHub sync failed: ${error}`); + } + }; + const addedCount = diffEntries.filter((entry) => entry.type === "added").length; const removedCount = diffEntries.filter((entry) => entry.type === "removed").length; const modifiedCount = diffEntries.filter((entry) => entry.type === "modified").length; @@ -280,7 +298,7 @@ export default function ActiveSession() { {/* Sync Indicator */} - + diff --git a/apps/inkcaliber-desk/src/pages/notes/active.tsx b/apps/inkcaliber-desk/src/pages/notes/active.tsx index 36ae345..241bbf0 100644 --- a/apps/inkcaliber-desk/src/pages/notes/active.tsx +++ b/apps/inkcaliber-desk/src/pages/notes/active.tsx @@ -3,16 +3,17 @@ import { useEditor } from "@tiptap/react"; import { RichTextEditor, Link, } from "@mantine/tiptap"; import { useSearchParams, useNavigate } from "react-router"; import { writeTextFile, readTextFile, mkdir, BaseDirectory, rename, stat, readDir } from "@tauri-apps/plugin-fs"; -import { TextInput, Group, Box, ActionIcon, Tooltip, Stack, Paper, Menu, Portal, Divider, Modal, Button, Text, SimpleGrid, Card } from "@mantine/core"; +import { TextInput, Group, Box, ActionIcon, Tooltip, Stack, Paper, Menu, Portal, Divider, Modal, Text, SimpleGrid, Card } from "@mantine/core"; import { HugeiconsIcon } from "@hugeicons/react"; -import { ArrowLeft01FreeIcons, FloppyDiskFreeIcons, Loading01FreeIcons, AlertCircle, Copy01FreeIcons, Scissor01FreeIcons, ClipboardFreeIcons, TextBoldFreeIcons, TextItalicFreeIcons, EraserFreeIcons, SquareFreeIcons, Link01FreeIcons, Cancel01FreeIcons, Search01FreeIcons } from "@hugeicons/core-free-icons"; +import { ArrowLeft01FreeIcons, FloppyDiskFreeIcons, Loading01FreeIcons, AlertCircle, Copy01FreeIcons, Scissor01FreeIcons, ClipboardFreeIcons, TextBoldFreeIcons, TextItalicFreeIcons, EraserFreeIcons, SquareFreeIcons, Link01FreeIcons } from "@hugeicons/core-free-icons"; import { Shell } from "../../components/shell"; -import { getStoredTheme, setStoredTheme } from "../../theme"; +import { getStoredTheme } from "../../theme"; import StarterKit from '@tiptap/starter-kit'; import Highlight from '@tiptap/extension-highlight'; import TextAlign from '@tiptap/extension-text-align'; import Superscript from '@tiptap/extension-superscript'; import SubScript from '@tiptap/extension-subscript'; +import { syncPathToGitHub } from "../../services/github-sync"; export default function NoteEditor() { @@ -24,12 +25,11 @@ export default function NoteEditor() { const [originalFileName, setOriginalFileName] = useState(initialFile || ""); const [syncStatus, setSyncStatus] = useState<"saved" | "syncing" | "error" | "unsaved">("saved"); const folder = "InkCaliber/notes"; - const [theme, setTheme] = useState(getStoredTheme().theme); + const [theme] = useState(getStoredTheme().theme); // Diagram linking state const [diagramModalOpened, setDiagramModalOpened] = useState(false); const [availableDiagrams, setAvailableDiagrams] = useState([]); - const [diagramSearch, setDiagramSearch] = useState(""); // Refs for async access to avoid stale closures @@ -48,7 +48,7 @@ export default function NoteEditor() { shouldRerenderOnTransaction: true, editorProps: { handleDOMEvents: { - click: (view, event) => { + click: (_view, event) => { const target = event.target as HTMLElement; const link = target.closest('a'); if (link && link.getAttribute('href')?.startsWith('/diagrams/')) { @@ -195,6 +195,24 @@ export default function NoteEditor() { setDiagramModalOpened(true); }; + const handleGitHubSync = async () => { + const targetName = fileNameRef.current || originalFileNameRef.current; + if (!targetName) { + alert("Please give the note a title before syncing."); + return; + } + + try { + setSyncStatus("syncing"); + await syncPathToGitHub(`notes/${targetName}.json`); + setSyncStatus("saved"); + } catch (error: any) { + console.error("GitHub sync failed", error); + setSyncStatus("error"); + alert(`GitHub sync failed: ${error}`); + } + }; + const insertDiagramLink = (diagramName: string) => { if (!editor) return; const href = `/diagrams/active?file=${encodeURIComponent(diagramName)}`; @@ -228,7 +246,7 @@ export default function NoteEditor() { - + diff --git a/apps/inkcaliber-desk/src/services/github-sync.ts b/apps/inkcaliber-desk/src/services/github-sync.ts new file mode 100644 index 0000000..8da0e70 --- /dev/null +++ b/apps/inkcaliber-desk/src/services/github-sync.ts @@ -0,0 +1,73 @@ +import { invoke } from "@tauri-apps/api/core"; + +const CONFIG_KEY = "inkcaliber.github.sync.config"; + +type GitHubSyncConfig = { + accessToken: string; + repoName: string; +}; + +type SyncResult = { + ok: boolean; + message: string; +}; + +const getConfig = (): GitHubSyncConfig | null => { + const raw = localStorage.getItem(CONFIG_KEY); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as GitHubSyncConfig; + if (!parsed.accessToken || !parsed.repoName) return null; + return parsed; + } catch { + return null; + } +}; + +const setConfig = (config: GitHubSyncConfig) => { + localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); +}; + +const promptForConfig = (): GitHubSyncConfig | null => { + const token = window.prompt("GitHub Personal Access Token (needs repo scope):")?.trim(); + if (!token) return null; + + const repoName = window.prompt("Private repository name to sync with:", "inkcaliber-sync")?.trim(); + if (!repoName) return null; + + const config = { accessToken: token, repoName }; + setConfig(config); + return config; +}; + +export const syncPathToGitHub = async (itemPath: string): Promise => { + const existing = getConfig(); + const config = existing ?? promptForConfig(); + + if (!config) { + throw new Error("GitHub sync cancelled."); + } + + try { + const result = await invoke("sync_to_github", { + request: { + accessToken: config.accessToken, + repoName: config.repoName, + itemPath, + }, + }); + + return result; + } catch (error: any) { + const message = String(error); + if (message.toLowerCase().includes("auth failed")) { + localStorage.removeItem(CONFIG_KEY); + } + throw error; + } +}; + +export const resetGitHubSyncConfig = () => { + localStorage.removeItem(CONFIG_KEY); +};