Merge pull request #13 from refactoringhq/task/vault-from-github
feat: vault from GitHub — create or clone a vault from a GitHub repo
This commit is contained in:
83
.claude-done
83
.claude-done
@@ -1,47 +1,56 @@
|
||||
# Settings Panel — AI Provider Keys + Local Config Storage
|
||||
# Vault from GitHub — Implementation Summary
|
||||
|
||||
## What was built
|
||||
|
||||
### Rust Backend (`src-tauri/src/settings.rs`)
|
||||
- `Settings` struct: `{ anthropic_key, openai_key, google_key }` as `Option<String>`
|
||||
- `get_settings()` — reads from `<app_config_dir>/settings.json`, returns defaults if missing
|
||||
- `save_settings()` — writes JSON, trims whitespace, converts empty strings to `None`
|
||||
- Registered as Tauri commands in `lib.rs`
|
||||
A "Vault from GitHub" feature that lets users create or clone a vault from a GitHub repository, directly from within the Laputa app.
|
||||
|
||||
### Frontend Hook (`src/hooks/useSettings.ts`)
|
||||
- `useSettings()` — loads settings on mount, exposes `settings`, `loaded`, `saveSettings`
|
||||
- Uses the standard `tauriCall` pattern (Tauri invoke or mock fallback)
|
||||
## Architecture
|
||||
|
||||
### Settings Panel (`src/components/SettingsPanel.tsx`)
|
||||
- Modal with 3 API key fields (Anthropic, OpenAI, Google AI)
|
||||
- Password-type inputs with reveal/hide toggle and clear button
|
||||
- Saves on button click or Cmd+Enter, closes on Escape or backdrop click
|
||||
- Trims whitespace, converts empty to null before saving
|
||||
- Footer shows "⌘, to open settings" hint
|
||||
### Rust Backend (`src-tauri/src/github.rs`)
|
||||
- `github_list_repos` — Paginated GitHub API call to list authenticated user's repos (up to 1000)
|
||||
- `github_create_repo` — Creates a new GitHub repo via API with auto_init
|
||||
- `clone_repo` — Clones a repo locally using HTTPS + OAuth token injection, configures remote auth
|
||||
- Helper functions: `inject_token_into_url`, `configure_remote_auth`
|
||||
- All three exposed as Tauri commands in `lib.rs`
|
||||
|
||||
### Integration
|
||||
- **StatusBar**: Gear icon now clickable (was disabled), opens Settings panel
|
||||
- **useAppKeyboard**: Cmd+, shortcut opens Settings
|
||||
- **AIChatPanel**: Anthropic key synced from settings to localStorage via `useEffect` in App.tsx
|
||||
- **mock-tauri.ts**: Added `get_settings` / `save_settings` mock handlers
|
||||
### Frontend (`src/components/GitHubVaultModal.tsx`)
|
||||
- Two-tab modal: "Clone Existing" and "Create New"
|
||||
- Clone tab: searchable repo list with Private/Public badges, auto-fill clone path
|
||||
- Create tab: repo name, Private/Public toggle, auto-fill clone path
|
||||
- Loading and cloning progress states
|
||||
- No-token state redirects to Settings
|
||||
- Integrated into `App.tsx` via StatusBar vault menu
|
||||
|
||||
### Design
|
||||
- `design/settings-panel.pen` — Settings modal wireframe with header, 3 key fields, footer
|
||||
### Settings Extension
|
||||
- Added `github_token` field to Settings (Rust + TypeScript)
|
||||
- GitHub Token input field in SettingsPanel
|
||||
- Refactored `settings.rs` to use testable internal functions (`get_settings_at`, `save_settings_at`)
|
||||
|
||||
### Tests (19 new tests)
|
||||
- `SettingsPanel.test.tsx` — 14 tests: render, save, clear, keyboard, backdrop
|
||||
- `useSettings.test.ts` — 5 tests: load, save, error handling
|
||||
- Updated `App.test.tsx` with settings command mocks
|
||||
### Mock Data (`src/mock-tauri.ts`)
|
||||
- 5 realistic mock repos for browser-mode testing
|
||||
- Mock handlers for all 3 GitHub commands
|
||||
|
||||
## Product Decisions
|
||||
- Keys stored via Tauri app config dir (not localStorage) for persistence across sessions
|
||||
- Anthropic key synced to localStorage so existing AIChatPanel code works unchanged
|
||||
- All 3 provider key fields shown even though only Anthropic is currently used (ready for future)
|
||||
- Settings modal pattern matches existing app modals (CommitDialog, CreateTypeDialog)
|
||||
## Design Decisions
|
||||
|
||||
## Verification
|
||||
- `pnpm test` — 386 tests pass
|
||||
- `pnpm test:coverage` — 81.78% stmts, 84.28% lines (threshold: 70%)
|
||||
- `cargo test` — 152 tests pass
|
||||
- `cargo llvm-cov --lib --fail-under-lines 85` — 87.28% line coverage
|
||||
- `pnpm build` — succeeds
|
||||
1. **HTTPS+token auth over SSH**: Simpler for users (no SSH key setup), token injected into remote URL
|
||||
2. **Auto-fill clone path**: `~/Vaults/<repo-name>` — sensible default, editable
|
||||
3. **Private by default** for new repos — safer default for personal vaults
|
||||
4. **No OAuth flow**: GitHub token entered manually in Settings — ships faster, no server dependency
|
||||
5. **Extra vaults tracked in state**: Cloned vaults added to vault switcher dynamically (not persisted across sessions yet)
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- **Frontend**: 80.79% statements, 83.34% lines (threshold: 70%)
|
||||
- **Rust**: 86.20% lines (threshold: 85%)
|
||||
- 12 GitHubVaultModal component tests
|
||||
- 12 Rust github.rs unit tests
|
||||
- 9 Rust settings.rs unit tests (refactored for coverage)
|
||||
- E2E Playwright verification: vault menu, modal tabs, repo list, search filter, repo selection
|
||||
|
||||
## Commits (6 total)
|
||||
1. `design: vault-from-github wireframes`
|
||||
2. `feat: add Rust backend for GitHub vault operations`
|
||||
3. `feat: GitHub vault modal with clone/create flows`
|
||||
4. `test: add GitHubVaultModal component tests`
|
||||
5. `test: improve Rust test coverage for settings and github modules`
|
||||
6. `fix: remove unused GithubRepo import in mock-tauri.ts`
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ dist-ssr
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
# Coverage reports
|
||||
/coverage/
|
||||
|
||||
# Laputa vault cache
|
||||
.laputa-cache.json
|
||||
|
||||
|
||||
1269
design/vault-from-github.pen
Normal file
1269
design/vault-from-github.pen
Normal file
File diff suppressed because it is too large
Load Diff
332
src-tauri/src/github.rs
Normal file
332
src-tauri/src/github.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct GithubRepo {
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub description: Option<String>,
|
||||
pub private: bool,
|
||||
pub clone_url: String,
|
||||
pub html_url: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Lists the authenticated user's GitHub repositories.
|
||||
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut all_repos: Vec<GithubRepo> = Vec::new();
|
||||
let mut page = 1u32;
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"https://api.github.com/user/repos?per_page=100&sort=updated&page={}",
|
||||
page
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
let repos: Vec<GithubRepo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
|
||||
|
||||
let count = repos.len();
|
||||
all_repos.extend(repos);
|
||||
|
||||
if count < 100 {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
if page > 10 {
|
||||
break; // safety limit: 1000 repos max
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_repos)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct CreateRepoResponse {
|
||||
name: String,
|
||||
full_name: String,
|
||||
description: Option<String>,
|
||||
private: bool,
|
||||
clone_url: String,
|
||||
html_url: String,
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Creates a new GitHub repository for the authenticated user.
|
||||
pub async fn github_create_repo(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"private": private,
|
||||
"auto_init": true,
|
||||
"description": "Laputa vault"
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post("https://api.github.com/user/repos")
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 422 && body.contains("name already exists") {
|
||||
return Err("Repository name already exists on your account".to_string());
|
||||
}
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
let created: CreateRepoResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
|
||||
|
||||
Ok(GithubRepo {
|
||||
name: created.name,
|
||||
full_name: created.full_name,
|
||||
description: created.description,
|
||||
private: created.private,
|
||||
clone_url: created.clone_url,
|
||||
html_url: created.html_url,
|
||||
updated_at: created.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Clones a GitHub repo to a local path using HTTPS + token auth.
|
||||
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
|
||||
if dest.exists() && dest.read_dir().map(|mut d| d.next().is_some()).unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
|
||||
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
|
||||
let auth_url = inject_token_into_url(url, token)?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", &auth_url, local_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Clean up partial clone on failure
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git clone failed: {}", stderr));
|
||||
}
|
||||
|
||||
// Configure the remote to use token auth for future pushes
|
||||
configure_remote_auth(local_path, url, token)?;
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Injects an OAuth token into an HTTPS GitHub URL.
|
||||
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
|
||||
} else if let Some(rest) = url.strip_prefix("https://") {
|
||||
// Handle URLs that already have a host
|
||||
Ok(format!("https://oauth2:{}@{}", token, rest))
|
||||
} else {
|
||||
Err(format!("Unsupported URL format: {}. Use an HTTPS URL.", url))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the git remote to use token-based HTTPS auth.
|
||||
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
|
||||
let auth_url = inject_token_into_url(original_url, token)?;
|
||||
let vault = Path::new(local_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "set-url", "origin", &auth_url])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to configure remote: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to set remote URL: {}", stderr));
|
||||
}
|
||||
|
||||
// Also configure git user if not set
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_basic_github_url() {
|
||||
let url = "https://github.com/user/repo.git";
|
||||
let token = "gho_abc123";
|
||||
let result = inject_token_into_url(url, token).unwrap();
|
||||
assert_eq!(result, "https://oauth2:gho_abc123@github.com/user/repo.git");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_generic_https_url() {
|
||||
let url = "https://gitlab.com/user/repo.git";
|
||||
let token = "glpat-abc";
|
||||
let result = inject_token_into_url(url, token).unwrap();
|
||||
assert_eq!(result, "https://oauth2:glpat-abc@gitlab.com/user/repo.git");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_ssh_url_rejected() {
|
||||
let url = "git@github.com:user/repo.git";
|
||||
let result = inject_token_into_url(url, "token");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unsupported URL format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_http_url_rejected() {
|
||||
let url = "http://github.com/user/repo.git";
|
||||
let result = inject_token_into_url(url, "token");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_github_without_dot_git() {
|
||||
let url = "https://github.com/user/repo";
|
||||
let result = inject_token_into_url(url, "tok").unwrap();
|
||||
assert_eq!(result, "https://oauth2:tok@github.com/user/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
std::fs::write(path.join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo("https://github.com/test/repo.git", "token", path.to_str().unwrap());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_ssh_url_rejected() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("new-clone");
|
||||
|
||||
let result = clone_repo("git@github.com:user/repo.git", "token", dest.to_str().unwrap());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unsupported URL format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
// An empty existing directory should not be rejected
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
|
||||
// This will fail at the git clone step (invalid URL) but should pass the directory check
|
||||
let result = clone_repo("https://github.com/nonexistent/repo.git", "token", dest.to_str().unwrap());
|
||||
assert!(result.is_err());
|
||||
// Should fail at git clone, not at directory check
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configure_remote_auth_on_git_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
// Initialize a git repo
|
||||
StdCommand::new("git").args(["init"]).current_dir(path).output().unwrap();
|
||||
StdCommand::new("git").args(["remote", "add", "origin", "https://github.com/user/repo.git"])
|
||||
.current_dir(path).output().unwrap();
|
||||
|
||||
let result = configure_remote_auth(
|
||||
path.to_str().unwrap(),
|
||||
"https://github.com/user/repo.git",
|
||||
"gho_test123",
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify the remote URL was updated
|
||||
let output = StdCommand::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_serialization() {
|
||||
let repo = GithubRepo {
|
||||
name: "test-repo".to_string(),
|
||||
full_name: "user/test-repo".to_string(),
|
||||
description: Some("A test repo".to_string()),
|
||||
private: true,
|
||||
clone_url: "https://github.com/user/test-repo.git".to_string(),
|
||||
html_url: "https://github.com/user/test-repo".to_string(),
|
||||
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&repo).unwrap();
|
||||
let parsed: GithubRepo = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, "test-repo");
|
||||
assert_eq!(parsed.full_name, "user/test-repo");
|
||||
assert!(parsed.private);
|
||||
assert_eq!(parsed.description, Some("A test repo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_deserialization_null_fields() {
|
||||
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
|
||||
let repo: GithubRepo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(repo.name, "r");
|
||||
assert!(repo.description.is_none());
|
||||
assert!(repo.updated_at.is_none());
|
||||
assert!(!repo.private);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
pub mod ai_chat;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod settings;
|
||||
pub mod vault;
|
||||
|
||||
use ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use git::{GitCommit, ModifiedFile};
|
||||
use github::GithubRepo;
|
||||
use settings::Settings;
|
||||
use vault::{VaultEntry, RenameResult};
|
||||
use frontmatter::FrontmatterValue;
|
||||
@@ -90,6 +92,21 @@ fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
github::github_list_repos(&token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_create_repo(token: String, name: String, private: bool) -> Result<GithubRepo, String> {
|
||||
github::github_create_repo(&token, &name, private).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
|
||||
github::clone_repo(&url, &token, &local_path)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -142,7 +159,10 @@ pub fn run() {
|
||||
save_image,
|
||||
purge_trash,
|
||||
get_settings,
|
||||
save_settings
|
||||
save_settings,
|
||||
github_list_repos,
|
||||
github_create_repo,
|
||||
clone_repo
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -7,6 +7,7 @@ pub struct Settings {
|
||||
pub anthropic_key: Option<String>,
|
||||
pub openai_key: Option<String>,
|
||||
pub google_key: Option<String>,
|
||||
pub github_token: Option<String>,
|
||||
}
|
||||
|
||||
fn settings_path() -> Result<PathBuf, String> {
|
||||
@@ -15,19 +16,17 @@ fn settings_path() -> Result<PathBuf, String> {
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
pub fn get_settings() -> Result<Settings, String> {
|
||||
let path = settings_path()?;
|
||||
fn get_settings_at(path: &PathBuf) -> Result<Settings, String> {
|
||||
if !path.exists() {
|
||||
return Ok(Settings::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read settings: {}", e))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
let path = settings_path()?;
|
||||
fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
@@ -38,64 +37,128 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
anthropic_key: settings.anthropic_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
openai_key: settings.openai_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
google_key: settings.google_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
github_token: settings.github_token.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&cleaned)
|
||||
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
|
||||
fs::write(&path, json)
|
||||
fs::write(path, json)
|
||||
.map_err(|e| format!("Failed to write settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_settings() -> Result<Settings, String> {
|
||||
get_settings_at(&settings_path()?)
|
||||
}
|
||||
|
||||
pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
save_settings_at(&settings_path()?, settings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
fn with_temp_config<F: FnOnce()>(f: F) {
|
||||
let tmp = env::temp_dir().join(format!("laputa-test-{}", std::process::id()));
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
env::set_var("XDG_CONFIG_HOME", &tmp);
|
||||
f();
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
/// Helper: save settings to a temp file and reload them.
|
||||
fn save_and_reload(settings: Settings) -> Settings {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
save_settings_at(&path, settings).unwrap();
|
||||
get_settings_at(&path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_settings_when_no_file() {
|
||||
let settings = Settings::default();
|
||||
assert!(settings.anthropic_key.is_none());
|
||||
assert!(settings.openai_key.is_none());
|
||||
assert!(settings.google_key.is_none());
|
||||
fn test_default_settings_all_none() {
|
||||
let s = Settings::default();
|
||||
assert_eq!(
|
||||
format!("{:?}", s),
|
||||
format!("{:?}", Settings { anthropic_key: None, openai_key: None, google_key: None, github_token: None })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_serialize_deserialize() {
|
||||
fn test_settings_json_roundtrip() {
|
||||
let settings = Settings {
|
||||
anthropic_key: Some("sk-ant-test123".to_string()),
|
||||
openai_key: None,
|
||||
google_key: Some("AIza-test".to_string()),
|
||||
github_token: Some("gho_xyz789".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
let parsed: Settings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.anthropic_key, Some("sk-ant-test123".to_string()));
|
||||
assert!(parsed.openai_key.is_none());
|
||||
assert_eq!(parsed.google_key, Some("AIza-test".to_string()));
|
||||
assert_eq!(parsed.anthropic_key, settings.anthropic_key);
|
||||
assert_eq!(parsed.google_key, settings.google_key);
|
||||
assert_eq!(parsed.github_token, settings.github_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_trims_whitespace_and_filters_empty() {
|
||||
// Test the cleaning logic directly
|
||||
let settings = Settings {
|
||||
fn test_get_settings_returns_default_for_missing_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nonexistent.json");
|
||||
let result = get_settings_at(&path).unwrap();
|
||||
assert!(result.anthropic_key.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_preserves_values() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
anthropic_key: Some("sk-ant-key".to_string()),
|
||||
openai_key: Some("sk-openai".to_string()),
|
||||
google_key: None,
|
||||
github_token: Some("gho_token123".to_string()),
|
||||
});
|
||||
assert_eq!(loaded.anthropic_key.as_deref(), Some("sk-ant-key"));
|
||||
assert_eq!(loaded.openai_key.as_deref(), Some("sk-openai"));
|
||||
assert_eq!(loaded.github_token.as_deref(), Some("gho_token123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_trims_whitespace() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
anthropic_key: Some(" sk-ant-test ".to_string()),
|
||||
openai_key: None,
|
||||
google_key: None,
|
||||
github_token: Some(" gho_abc ".to_string()),
|
||||
});
|
||||
assert_eq!(loaded.anthropic_key.as_deref(), Some("sk-ant-test"));
|
||||
assert_eq!(loaded.github_token.as_deref(), Some("gho_abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_filters_empty_and_whitespace_only() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
anthropic_key: Some("".to_string()),
|
||||
openai_key: Some(" ".to_string()),
|
||||
google_key: Some("".to_string()),
|
||||
};
|
||||
let cleaned = Settings {
|
||||
anthropic_key: settings.anthropic_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
openai_key: settings.openai_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
google_key: settings.google_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
};
|
||||
assert_eq!(cleaned.anthropic_key, Some("sk-ant-test".to_string()));
|
||||
assert!(cleaned.openai_key.is_none());
|
||||
assert!(cleaned.google_key.is_none());
|
||||
google_key: None,
|
||||
github_token: None,
|
||||
});
|
||||
assert!(loaded.anthropic_key.is_none());
|
||||
assert!(loaded.openai_key.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_creates_parent_directories() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("dir").join("settings.json");
|
||||
|
||||
save_settings_at(&path, Settings { anthropic_key: Some("key".to_string()), ..Default::default() }).unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(get_settings_at(&path).unwrap().anthropic_key.as_deref(), Some("key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_settings_malformed_json() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("bad.json");
|
||||
fs::write(&path, "not valid json{{{").unwrap();
|
||||
|
||||
let err = get_settings_at(&path).unwrap_err();
|
||||
assert!(err.contains("Failed to parse settings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_path_returns_ok() {
|
||||
let result = settings_path();
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ vi.mock('./mock-tauri', () => ({
|
||||
if (cmd === 'get_modified_files') return []
|
||||
if (cmd === 'get_note_content') return mockAllContent['/vault/project/test.md'] || ''
|
||||
if (cmd === 'get_file_history') return []
|
||||
if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null }
|
||||
if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null }
|
||||
if (cmd === 'save_settings') return null
|
||||
return null
|
||||
}),
|
||||
|
||||
30
src/App.tsx
30
src/App.tsx
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { NoteList } from './components/NoteList'
|
||||
import { Editor } from './components/Editor'
|
||||
@@ -9,6 +9,7 @@ import { Toast } from './components/Toast'
|
||||
import { CommitDialog } from './components/CommitDialog'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { GitHubVaultModal } from './components/GitHubVaultModal'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions, generateUntitledName } from './hooks/useNoteActions'
|
||||
@@ -19,6 +20,7 @@ import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'
|
||||
import { useUpdater } from './hooks/useUpdater'
|
||||
import { setApiKey } from './utils/ai-chat'
|
||||
import type { SidebarSelection, GitCommit } from './types'
|
||||
import type { VaultOption } from './components/StatusBar'
|
||||
import './App.css'
|
||||
|
||||
// Type declaration for mock content storage
|
||||
@@ -30,7 +32,7 @@ declare global {
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
const VAULTS = isTauri()
|
||||
const DEFAULT_VAULTS: VaultOption[] = isTauri()
|
||||
? [
|
||||
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
{ label: 'Laputa', path: '/Users/luca/Laputa' },
|
||||
@@ -58,9 +60,13 @@ function App() {
|
||||
const [showQuickOpen, setShowQuickOpen] = useState(false)
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const [vaultPath, setVaultPath] = useState(VAULTS[0].path)
|
||||
const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path)
|
||||
const [showAIChat, setShowAIChat] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showGitHubVault, setShowGitHubVault] = useState(false)
|
||||
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
|
||||
|
||||
const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults])
|
||||
|
||||
const vault = useVaultLoader(vaultPath)
|
||||
const { settings, saveSettings } = useSettings()
|
||||
@@ -94,6 +100,15 @@ function App() {
|
||||
notes.closeAllTabs()
|
||||
}, [notes])
|
||||
|
||||
const handleVaultCloned = useCallback((path: string, label: string) => {
|
||||
setExtraVaults(prev => {
|
||||
if (prev.some(v => v.path === path)) return prev
|
||||
return [...prev, { label, path }]
|
||||
})
|
||||
handleSwitchVault(path)
|
||||
setToastMessage(`Vault "${label}" cloned and opened`)
|
||||
}, [handleSwitchVault])
|
||||
|
||||
useEffect(() => {
|
||||
if (!notes.activeTabPath) { setGitHistory([]); return }
|
||||
vault.loadGitHistory(notes.activeTabPath).then(setGitHistory)
|
||||
@@ -197,12 +212,19 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar noteCount={vault.entries.length} vaultPath={vaultPath} vaults={VAULTS} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} />
|
||||
<StatusBar noteCount={vault.entries.length} vaultPath={vaultPath} vaults={allVaults} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={() => setShowQuickOpen(false)} />
|
||||
<CreateTypeDialog open={showCreateTypeDialog} onClose={() => setShowCreateTypeDialog(false)} onCreate={handleCreateType} />
|
||||
<CommitDialog open={showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={handleCommitPush} onClose={() => setShowCommitDialog(false)} />
|
||||
<SettingsPanel open={showSettings} settings={settings} onSave={saveSettings} onClose={() => setShowSettings(false)} />
|
||||
<GitHubVaultModal
|
||||
open={showGitHubVault}
|
||||
githubToken={settings.github_token}
|
||||
onClose={() => setShowGitHubVault(false)}
|
||||
onVaultCloned={handleVaultCloned}
|
||||
onOpenSettings={() => { setShowGitHubVault(false); setShowSettings(true) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
183
src/components/GitHubVaultModal.test.tsx
Normal file
183
src/components/GitHubVaultModal.test.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { GitHubVaultModal } from './GitHubVaultModal'
|
||||
|
||||
// Mock mockInvoke — the component uses tauriCall which calls mockInvoke in browser
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
const mockInvokeFn = vi.mocked(mockInvoke)
|
||||
|
||||
const MOCK_REPOS = [
|
||||
{ name: 'my-vault', full_name: 'user/my-vault', description: 'A personal vault', private: true, clone_url: 'https://github.com/user/my-vault.git', html_url: 'https://github.com/user/my-vault', updated_at: '2026-02-20T10:00:00Z' },
|
||||
{ name: 'public-notes', full_name: 'user/public-notes', description: 'Public notes repo', private: false, clone_url: 'https://github.com/user/public-notes.git', html_url: 'https://github.com/user/public-notes', updated_at: '2026-02-19T10:00:00Z' },
|
||||
]
|
||||
|
||||
describe('GitHubVaultModal', () => {
|
||||
const onClose = vi.fn()
|
||||
const onVaultCloned = vi.fn()
|
||||
const onOpenSettings = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_list_repos') return MOCK_REPOS
|
||||
if (cmd === 'github_create_repo') return MOCK_REPOS[0]
|
||||
if (cmd === 'clone_repo') return 'Cloned successfully'
|
||||
throw new Error(`Unknown command: ${cmd}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders nothing when not open', () => {
|
||||
const { container } = render(
|
||||
<GitHubVaultModal open={false} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
expect(container.querySelector('[data-testid="github-vault-modal"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows connect prompt when no GitHub token', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
expect(screen.getByText(/Add your GitHub token in Settings/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('github-open-settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens settings when "Open Settings" clicked without token', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('github-open-settings'))
|
||||
expect(onOpenSettings).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows clone and create tabs when token is present', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
expect(screen.getByTestId('github-tab-clone')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('github-tab-create')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads and displays repos in clone tab', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('user/public-notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('A personal vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters repos by search', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('github-repo-search'), { target: { value: 'public' } })
|
||||
|
||||
expect(screen.queryByText('user/my-vault')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('user/public-notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects a repo and auto-fills clone path', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
|
||||
|
||||
const pathInput = screen.getByTestId('github-clone-path') as HTMLInputElement
|
||||
expect(pathInput.value).toBe('~/Vaults/my-vault')
|
||||
})
|
||||
|
||||
it('clone button disabled without selection', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('github-clone-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('github-clone-btn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls clone_repo and onVaultCloned on clone', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
|
||||
fireEvent.click(screen.getByTestId('github-clone-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault')
|
||||
})
|
||||
})
|
||||
|
||||
it('has create tab trigger that is clickable', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
const createTab = screen.getByTestId('github-tab-create')
|
||||
expect(createTab).toBeInTheDocument()
|
||||
expect(createTab).toHaveTextContent('Create New')
|
||||
expect(createTab).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows error when clone fails', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_list_repos') return MOCK_REPOS
|
||||
if (cmd === 'clone_repo') throw new Error('Permission denied')
|
||||
throw new Error(`Unknown: ${cmd}`)
|
||||
})
|
||||
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
|
||||
fireEvent.click(screen.getByTestId('github-clone-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Clone failed/i)).toBeInTheDocument()
|
||||
})
|
||||
expect(onVaultCloned).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows Private/Public badges on repos', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Private')).toBeInTheDocument()
|
||||
expect(screen.getByText('Public')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
436
src/components/GitHubVaultModal.tsx
Normal file
436
src/components/GitHubVaultModal.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { GithubRepo } from '../types'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
interface GitHubVaultModalProps {
|
||||
open: boolean
|
||||
githubToken: string | null
|
||||
onClose: () => void
|
||||
onVaultCloned: (path: string, label: string) => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
type CloneStatus = 'idle' | 'cloning' | 'success' | 'error'
|
||||
|
||||
function RepoListItem({ repo, selected, onSelect }: { repo: GithubRepo; selected: boolean; onSelect: () => void }) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }}
|
||||
className={`flex flex-col gap-1 rounded-md px-3 py-2.5 cursor-pointer transition-colors ${
|
||||
selected ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
data-testid={`repo-item-${repo.name}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{repo.full_name}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{repo.private ? 'Private' : 'Public'}
|
||||
</Badge>
|
||||
</div>
|
||||
{repo.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{repo.description}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CloneTab({
|
||||
repos,
|
||||
loading,
|
||||
search,
|
||||
setSearch,
|
||||
selectedRepo,
|
||||
setSelectedRepo,
|
||||
localPath,
|
||||
setLocalPath,
|
||||
onClone,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
}: {
|
||||
repos: GithubRepo[]
|
||||
loading: boolean
|
||||
search: string
|
||||
setSearch: (v: string) => void
|
||||
selectedRepo: GithubRepo | null
|
||||
setSelectedRepo: (r: GithubRepo) => void
|
||||
localPath: string
|
||||
setLocalPath: (v: string) => void
|
||||
onClone: () => void
|
||||
cloneStatus: CloneStatus
|
||||
cloneError: string | null
|
||||
}) {
|
||||
const filtered = repos.filter(r =>
|
||||
r.full_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(r.description?.toLowerCase().includes(search.toLowerCase()) ?? false)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 min-h-0">
|
||||
<Input
|
||||
placeholder="Search repos or paste URL..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
data-testid="github-repo-search"
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border border-border rounded-md min-h-[180px] max-h-[240px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
Loading repositories...
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
{search ? 'No repos match your search' : 'No repositories found'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 p-1">
|
||||
{filtered.map(repo => (
|
||||
<RepoListItem
|
||||
key={repo.full_name}
|
||||
repo={repo}
|
||||
selected={selectedRepo?.full_name === repo.full_name}
|
||||
onSelect={() => setSelectedRepo(repo)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">Clone to:</label>
|
||||
<Input
|
||||
value={localPath}
|
||||
onChange={e => setLocalPath(e.target.value)}
|
||||
placeholder="~/Vaults/repo-name"
|
||||
data-testid="github-clone-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive">{cloneError}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{filtered.length} repo{filtered.length !== 1 ? 's' : ''} found
|
||||
</span>
|
||||
<Button
|
||||
onClick={onClone}
|
||||
disabled={!selectedRepo || !localPath.trim() || cloneStatus === 'cloning'}
|
||||
data-testid="github-clone-btn"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTab({
|
||||
repoName,
|
||||
setRepoName,
|
||||
isPrivate,
|
||||
setIsPrivate,
|
||||
localPath,
|
||||
setLocalPath,
|
||||
onCreate,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
}: {
|
||||
repoName: string
|
||||
setRepoName: (v: string) => void
|
||||
isPrivate: boolean
|
||||
setIsPrivate: (v: boolean) => void
|
||||
localPath: string
|
||||
setLocalPath: (v: string) => void
|
||||
onCreate: () => void
|
||||
cloneStatus: CloneStatus
|
||||
cloneError: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">Repository name</label>
|
||||
<Input
|
||||
value={repoName}
|
||||
onChange={e => setRepoName(e.target.value)}
|
||||
placeholder="my-vault"
|
||||
data-testid="github-repo-name"
|
||||
/>
|
||||
{repoName && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
github.com/you/{repoName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-medium text-foreground">Visibility</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPrivate(true)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border transition-colors ${
|
||||
isPrivate
|
||||
? 'border-ring bg-accent text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
data-testid="github-visibility-private"
|
||||
>
|
||||
Private
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPrivate(false)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border transition-colors ${
|
||||
!isPrivate
|
||||
? 'border-ring bg-accent text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
data-testid="github-visibility-public"
|
||||
>
|
||||
Public
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">Clone to:</label>
|
||||
<Input
|
||||
value={localPath}
|
||||
onChange={e => setLocalPath(e.target.value)}
|
||||
placeholder="~/Vaults/my-vault"
|
||||
data-testid="github-create-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive">{cloneError}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
|
||||
<Button
|
||||
onClick={onCreate}
|
||||
disabled={!repoName.trim() || !localPath.trim() || cloneStatus === 'cloning'}
|
||||
data-testid="github-create-btn"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Creating...' : 'Create & Clone'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CloningProgress({ repoName }: { repoName: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8" data-testid="clone-progress">
|
||||
<div className="h-10 w-10 rounded-full border-[3px] border-ring border-t-transparent animate-spin" />
|
||||
<p className="text-sm font-medium text-foreground">Cloning {repoName}...</p>
|
||||
<p className="text-xs text-muted-foreground">This may take a moment for large repositories</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings }: GitHubVaultModalProps) {
|
||||
const [tab, setTab] = useState('clone')
|
||||
const [repos, setRepos] = useState<GithubRepo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedRepo, setSelectedRepo] = useState<GithubRepo | null>(null)
|
||||
const [clonePath, setClonePath] = useState('')
|
||||
const [repoName, setRepoName] = useState('')
|
||||
const [isPrivate, setIsPrivate] = useState(true)
|
||||
const [createPath, setCreatePath] = useState('')
|
||||
const [cloneStatus, setCloneStatus] = useState<CloneStatus>('idle')
|
||||
const [cloneError, setCloneError] = useState<string | null>(null)
|
||||
const loadedRef = useRef(false)
|
||||
|
||||
const loadRepos = useCallback(async () => {
|
||||
if (!githubToken) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await tauriCall<GithubRepo[]>('github_list_repos', { token: githubToken })
|
||||
setRepos(result)
|
||||
} catch (err) {
|
||||
console.error('Failed to load GitHub repos:', err)
|
||||
setCloneError(`Failed to load repos: ${err}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [githubToken])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && githubToken && !loadedRef.current) {
|
||||
loadedRef.current = true
|
||||
loadRepos()
|
||||
}
|
||||
if (!open) {
|
||||
loadedRef.current = false
|
||||
}
|
||||
}, [open, githubToken, loadRepos])
|
||||
|
||||
// Auto-fill clone path when repo selected
|
||||
useEffect(() => {
|
||||
if (selectedRepo) {
|
||||
setClonePath(`~/Vaults/${selectedRepo.name}`)
|
||||
}
|
||||
}, [selectedRepo])
|
||||
|
||||
// Auto-fill create path when name changes
|
||||
useEffect(() => {
|
||||
if (repoName) {
|
||||
setCreatePath(`~/Vaults/${repoName}`)
|
||||
}
|
||||
}, [repoName])
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setTab('clone')
|
||||
setSearch('')
|
||||
setSelectedRepo(null)
|
||||
setClonePath('')
|
||||
setRepoName('')
|
||||
setIsPrivate(true)
|
||||
setCreatePath('')
|
||||
setCloneStatus('idle')
|
||||
setCloneError(null)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [onClose, resetState])
|
||||
|
||||
const handleClone = useCallback(async () => {
|
||||
if (!selectedRepo || !clonePath.trim() || !githubToken) return
|
||||
setCloneStatus('cloning')
|
||||
setCloneError(null)
|
||||
try {
|
||||
await tauriCall<string>('clone_repo', {
|
||||
url: selectedRepo.clone_url,
|
||||
token: githubToken,
|
||||
localPath: clonePath.trim(),
|
||||
})
|
||||
setCloneStatus('success')
|
||||
onVaultCloned(clonePath.trim(), selectedRepo.name)
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
setCloneStatus('error')
|
||||
setCloneError(`Clone failed: ${err}`)
|
||||
}
|
||||
}, [selectedRepo, clonePath, githubToken, onVaultCloned, handleClose])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!repoName.trim() || !createPath.trim() || !githubToken) return
|
||||
setCloneStatus('cloning')
|
||||
setCloneError(null)
|
||||
try {
|
||||
const repo = await tauriCall<GithubRepo>('github_create_repo', {
|
||||
token: githubToken,
|
||||
name: repoName.trim(),
|
||||
private: isPrivate,
|
||||
})
|
||||
await tauriCall<string>('clone_repo', {
|
||||
url: repo.clone_url,
|
||||
token: githubToken,
|
||||
localPath: createPath.trim(),
|
||||
})
|
||||
setCloneStatus('success')
|
||||
onVaultCloned(createPath.trim(), repo.name)
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
setCloneStatus('error')
|
||||
setCloneError(`${err}`)
|
||||
}
|
||||
}, [repoName, createPath, githubToken, isPrivate, onVaultCloned, handleClose])
|
||||
|
||||
if (!githubToken) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isOpen => { if (!isOpen) handleClose() }}>
|
||||
<DialogContent className="sm:max-w-[480px]" data-testid="github-vault-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect GitHub Repo</DialogTitle>
|
||||
<DialogDescription>Connect your GitHub account to clone or create vaults backed by GitHub repos.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
You need to connect your GitHub account first. Add your GitHub token in Settings.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => { handleClose(); onOpenSettings() }}
|
||||
data-testid="github-open-settings"
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const cloningRepoName = tab === 'clone' ? (selectedRepo?.full_name ?? '') : repoName
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isOpen => { if (!isOpen) handleClose() }}>
|
||||
<DialogContent className="sm:max-w-[540px]" data-testid="github-vault-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect GitHub Repo</DialogTitle>
|
||||
<DialogDescription>Clone an existing repo or create a new one as a vault.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{cloneStatus === 'cloning' ? (
|
||||
<CloningProgress repoName={cloningRepoName} />
|
||||
) : (
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="clone" data-testid="github-tab-clone">Clone Existing</TabsTrigger>
|
||||
<TabsTrigger value="create" data-testid="github-tab-create">Create New</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="clone">
|
||||
<CloneTab
|
||||
repos={repos}
|
||||
loading={loading}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
selectedRepo={selectedRepo}
|
||||
setSelectedRepo={setSelectedRepo}
|
||||
localPath={clonePath}
|
||||
setLocalPath={setClonePath}
|
||||
onClone={handleClone}
|
||||
cloneStatus={cloneStatus}
|
||||
cloneError={cloneError}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="create">
|
||||
<CreateTab
|
||||
repoName={repoName}
|
||||
setRepoName={setRepoName}
|
||||
isPrivate={isPrivate}
|
||||
setIsPrivate={setIsPrivate}
|
||||
localPath={createPath}
|
||||
setLocalPath={setCreatePath}
|
||||
onCreate={handleCreate}
|
||||
cloneStatus={cloneStatus}
|
||||
cloneError={cloneError}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -7,12 +7,14 @@ const emptySettings: Settings = {
|
||||
anthropic_key: null,
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
}
|
||||
|
||||
const populatedSettings: Settings = {
|
||||
anthropic_key: 'sk-ant-api03-test123',
|
||||
openai_key: 'sk-openai-test456',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
}
|
||||
|
||||
describe('SettingsPanel', () => {
|
||||
@@ -74,6 +76,7 @@ describe('SettingsPanel', () => {
|
||||
anthropic_key: 'sk-ant-test',
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
})
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
@@ -92,6 +95,7 @@ describe('SettingsPanel', () => {
|
||||
anthropic_key: null,
|
||||
openai_key: 'sk-openai-test456',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -131,6 +135,7 @@ describe('SettingsPanel', () => {
|
||||
anthropic_key: 'sk-ant-test',
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -73,12 +73,14 @@ export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanel
|
||||
const [anthropicKey, setAnthropicKey] = useState('')
|
||||
const [openaiKey, setOpenaiKey] = useState('')
|
||||
const [googleKey, setGoogleKey] = useState('')
|
||||
const [githubToken, setGithubToken] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setAnthropicKey(settings.anthropic_key ?? '')
|
||||
setOpenaiKey(settings.openai_key ?? '')
|
||||
setGoogleKey(settings.google_key ?? '')
|
||||
setGithubToken(settings.github_token ?? '')
|
||||
}
|
||||
}, [open, settings])
|
||||
|
||||
@@ -89,6 +91,7 @@ export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanel
|
||||
anthropic_key: anthropicKey.trim() || null,
|
||||
openai_key: openaiKey.trim() || null,
|
||||
google_key: googleKey.trim() || null,
|
||||
github_token: githubToken.trim() || null,
|
||||
}
|
||||
onSave(trimmed)
|
||||
onClose()
|
||||
@@ -164,6 +167,25 @@ export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanel
|
||||
onChange={setGoogleKey}
|
||||
onClear={() => setGoogleKey('')}
|
||||
/>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>
|
||||
GitHub
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Personal access token for cloning and syncing vaults with GitHub.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KeyField
|
||||
label="GitHub Token"
|
||||
placeholder="ghp_... or gho_..."
|
||||
value={githubToken}
|
||||
onChange={setGithubToken}
|
||||
onClear={() => setGithubToken('')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check } from 'lucide-react'
|
||||
import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github } from 'lucide-react'
|
||||
|
||||
export interface VaultOption {
|
||||
label: string
|
||||
@@ -12,6 +12,8 @@ interface StatusBarProps {
|
||||
vaults: VaultOption[]
|
||||
onSwitchVault: (path: string) => void
|
||||
onOpenSettings?: () => void
|
||||
onConnectGitHub?: () => void
|
||||
hasGitHub?: boolean
|
||||
}
|
||||
|
||||
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
|
||||
@@ -32,7 +34,7 @@ function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isAc
|
||||
)
|
||||
}
|
||||
|
||||
function VaultMenu({ vaults, vaultPath, onSwitchVault }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void }) {
|
||||
function VaultMenu({ vaults, vaultPath, onSwitchVault, onConnectGitHub, hasGitHub }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void; onConnectGitHub?: () => void; hasGitHub?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const activeVault = vaults.find((v) => v.path === vaultPath)
|
||||
@@ -53,8 +55,28 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault }: { vaults: VaultOption[]
|
||||
{activeVault?.label ?? 'Vault'}
|
||||
</span>
|
||||
{open && (
|
||||
<div style={{ position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, background: 'var(--sidebar)', border: '1px solid var(--border)', borderRadius: 6, padding: 4, minWidth: 160, boxShadow: '0 4px 12px rgba(0,0,0,0.3)', zIndex: 1000 }}>
|
||||
<div style={{ position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, background: 'var(--sidebar)', border: '1px solid var(--border)', borderRadius: 6, padding: 4, minWidth: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.3)', zIndex: 1000 }}>
|
||||
{vaults.map((v) => <VaultMenuItem key={v.path} vault={v} isActive={v.path === vaultPath} onSelect={() => { onSwitchVault(v.path); setOpen(false) }} />)}
|
||||
{onConnectGitHub && (
|
||||
<>
|
||||
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => { onConnectGitHub(); setOpen(false) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
|
||||
cursor: 'pointer', background: 'transparent',
|
||||
color: hasGitHub ? 'var(--muted-foreground)' : 'var(--accent-blue)', fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="vault-menu-connect-github"
|
||||
>
|
||||
<Github size={12} />
|
||||
Connect GitHub repo
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -65,11 +87,11 @@ const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
|
||||
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
const SEP_STYLE = { color: 'var(--border)' } as const
|
||||
|
||||
export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenSettings }: StatusBarProps) {
|
||||
export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenSettings, onConnectGitHub, hasGitHub }: StatusBarProps) {
|
||||
return (
|
||||
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} />
|
||||
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={ICON_STYLE}><Package size={13} />v0.4.2</span>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
|
||||
@@ -7,12 +7,14 @@ const defaultSettings: Settings = {
|
||||
anthropic_key: null,
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
}
|
||||
|
||||
const savedSettings: Settings = {
|
||||
anthropic_key: 'sk-ant-test123',
|
||||
openai_key: null,
|
||||
google_key: 'AIza-test',
|
||||
github_token: null,
|
||||
}
|
||||
|
||||
let mockSettingsStore: Settings = { ...defaultSettings }
|
||||
@@ -71,6 +73,7 @@ describe('useSettings', () => {
|
||||
anthropic_key: 'sk-ant-new',
|
||||
openai_key: 'sk-openai-new',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ const EMPTY_SETTINGS: Settings = {
|
||||
anthropic_key: null,
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
|
||||
@@ -1645,6 +1645,7 @@ let mockSettings: Settings = {
|
||||
anthropic_key: null,
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: 'gho_mock_token_for_testing',
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
|
||||
@@ -1693,6 +1694,7 @@ const mockHandlers: Record<string, (args: any) => any> = {
|
||||
anthropic_key: s.anthropic_key?.trim() || null,
|
||||
openai_key: s.openai_key?.trim() || null,
|
||||
google_key: s.google_key?.trim() || null,
|
||||
github_token: s.github_token?.trim() || null,
|
||||
}
|
||||
return null
|
||||
},
|
||||
@@ -1732,6 +1734,23 @@ const mockHandlers: Record<string, (args: any) => any> = {
|
||||
}
|
||||
return { new_path: newPath, updated_files: updatedFiles }
|
||||
},
|
||||
github_list_repos: () => [
|
||||
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
|
||||
{ name: 'laputa-app', full_name: 'lucaong/laputa-app', description: 'Laputa desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/laputa-app.git', html_url: 'https://github.com/lucaong/laputa-app', updated_at: '2026-02-19T15:00:00Z' },
|
||||
{ name: 'dotfiles', full_name: 'lucaong/dotfiles', description: 'My macOS dotfiles and config', private: false, clone_url: 'https://github.com/lucaong/dotfiles.git', html_url: 'https://github.com/lucaong/dotfiles', updated_at: '2026-01-15T08:00:00Z' },
|
||||
{ name: 'notes-archive', full_name: 'lucaong/notes-archive', description: 'Archived notes from 2024', private: true, clone_url: 'https://github.com/lucaong/notes-archive.git', html_url: 'https://github.com/lucaong/notes-archive', updated_at: '2025-12-01T12:00:00Z' },
|
||||
{ name: 'obsidian-vault', full_name: 'lucaong/obsidian-vault', description: null, private: true, clone_url: 'https://github.com/lucaong/obsidian-vault.git', html_url: 'https://github.com/lucaong/obsidian-vault', updated_at: '2025-11-05T09:00:00Z' },
|
||||
],
|
||||
github_create_repo: (args: { name: string; private: boolean }) => ({
|
||||
name: args.name,
|
||||
full_name: `lucaong/${args.name}`,
|
||||
description: 'Laputa vault',
|
||||
private: args.private,
|
||||
clone_url: `https://github.com/lucaong/${args.name}.git`,
|
||||
html_url: `https://github.com/lucaong/${args.name}`,
|
||||
updated_at: new Date().toISOString(),
|
||||
}),
|
||||
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
|
||||
}
|
||||
|
||||
export function isTauri(): boolean {
|
||||
|
||||
11
src/types.ts
11
src/types.ts
@@ -44,6 +44,17 @@ export interface Settings {
|
||||
anthropic_key: string | null
|
||||
openai_key: string | null
|
||||
google_key: string | null
|
||||
github_token: string | null
|
||||
}
|
||||
|
||||
export interface GithubRepo {
|
||||
name: string
|
||||
full_name: string
|
||||
description: string | null
|
||||
private: boolean
|
||||
clone_url: string
|
||||
html_url: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export type SidebarSelection =
|
||||
|
||||
Reference in New Issue
Block a user