Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 248 additions & 4 deletions apps/inkcaliber-desk/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<SyncResponse, String> {
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<GitHubUserResponse, String> {
let output = run_curl(
"GET",
"https://api.github.com/user",
token,
None,
Some(&[200]),
)?;

serde_json::from_str::<GitHubUserResponse>(&output)
.map_err(|e| format!("Could not parse GitHub user response: {e}"))
}

fn ensure_private_repo(token: &str, owner: &str, repo_name: &str) -> Result<String, String> {
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::<GitHubRepoResponse>(&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::<GitHubRepoResponse>(&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<String, String> {
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<String, String> {
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::<i32>()
.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)]
Expand All @@ -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");
}
22 changes: 21 additions & 1 deletion apps/inkcaliber-desk/src/pages/chat/active.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -627,7 +647,7 @@ export default function ActiveChat() {
</Tooltip>
<Box style={{ width: '60%', height: '1px', backgroundColor: 'var(--mantine-color-gray-3)' }} />
<Tooltip color={syncStatus === 'error' ? 'red' : syncStatus === 'syncing' ? 'blue' : 'green'} label={`Sync: ${syncStatus}`} position="right">
<ActionIcon size="lg" variant="light" color={syncStatus === 'error' ? 'red' : syncStatus === 'syncing' ? 'blue' : 'green'} radius="md">
<ActionIcon size="lg" variant="light" color={syncStatus === 'error' ? 'red' : syncStatus === 'syncing' ? 'blue' : 'green'} radius="md" onClick={handleGitHubSync}>
<HugeiconsIcon icon={syncStatus === 'syncing' ? Loading01FreeIcons : syncStatus === 'error' ? AlertCircle : FloppyDiskFreeIcons} />
</ActionIcon>
</Tooltip>
Expand Down
20 changes: 19 additions & 1 deletion apps/inkcaliber-desk/src/pages/diagrams/active.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand All @@ -280,7 +298,7 @@ export default function ActiveSession() {

{/* Sync Indicator */}
<Tooltip color={syncStatus === 'error' ? 'red' : syncStatus === 'syncing' ? 'violet' : 'green'} label={`Sync: ${syncStatus}`} position="right">
<ActionIcon size="lg" variant="light" color={syncStatus === 'error' ? 'red' : syncStatus === 'syncing' ? 'violet' : 'green'} radius="md">
<ActionIcon size="lg" variant="light" color={syncStatus === 'error' ? 'red' : syncStatus === 'syncing' ? 'violet' : 'green'} radius="md" onClick={handleGitHubSync}>
<HugeiconsIcon icon={syncStatus === 'syncing' ? Loading01FreeIcons : syncStatus === 'error' ? AlertCircle : FloppyDiskFreeIcons} />
</ActionIcon>
</Tooltip>
Expand Down
Loading