diff --git a/.claude-done b/.claude-done index 72549737..3c329694 100644 --- a/.claude-done +++ b/.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` -- `get_settings()` — reads from `/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/` — 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` diff --git a/.gitignore b/.gitignore index dd0094e5..aa6365c8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ dist-ssr /test-results/ /playwright-report/ +# Coverage reports +/coverage/ + # Laputa vault cache .laputa-cache.json diff --git a/design/vault-from-github.pen b/design/vault-from-github.pen new file mode 100644 index 00000000..c3d9ea70 --- /dev/null +++ b/design/vault-from-github.pen @@ -0,0 +1,1269 @@ +{ + "version": "2.8", + "children": [ + { + "type": "frame", + "id": "ghv01", + "x": 68, + "y": 0, + "name": "GitHub Vault — Modal empty state", + "clip": true, + "width": 560, + "height": 480, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv01-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv01-title", + "name": "title", + "content": "Connect GitHub Repo", + "fontSize": 16, + "fontWeight": 600, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv01-close", + "name": "closeBtn", + "width": 24, + "height": 24, + "cornerRadius": 4, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ghv01-x", + "content": "X", + "fontSize": 14, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv01-tabs", + "name": "TabBar", + "width": "fill_container", + "height": 44, + "fill": "$--background", + "padding": [0, 24], + "gap": 0, + "alignItems": "end", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv01-tab-clone", + "name": "Tab: Clone Existing", + "padding": [8, 16], + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "bottom": 2 }, + "fill": "$--accent-blue" + }, + "children": [ + { + "type": "text", + "id": "ghv01-tab-clone-lbl", + "content": "Clone Existing", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv01-tab-create", + "name": "Tab: Create New", + "padding": [8, 16], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv01-tab-create-lbl", + "content": "Create New", + "fontSize": 13, + "fontWeight": 400, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv01-body", + "name": "Body: Empty State", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [32, 24], + "gap": 16, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "ghv01-icon", + "name": "githubIcon", + "width": 48, + "height": 48, + "cornerRadius": 24, + "fill": "$--accent", + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ghv01-gh", + "content": "GH", + "fontSize": 20, + "fontWeight": 600, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "text", + "id": "ghv01-heading", + "content": "Clone an existing GitHub repository", + "fontSize": 15, + "fontWeight": 600, + "fill": "$--foreground", + "textAlign": "center" + }, + { + "type": "text", + "id": "ghv01-desc", + "content": "Search your repos or paste a URL to clone a GitHub repo as a vault.", + "fontSize": 13, + "fill": "$--muted-foreground", + "textAlign": "center", + "width": 360 + }, + { + "type": "frame", + "id": "ghv01-search", + "name": "SearchInput", + "width": 400, + "height": 40, + "cornerRadius": 8, + "fill": "$--input", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [0, 12], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv01-placeholder", + "content": "Search repos or paste URL...", + "fontSize": 13, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv01-footer", + "name": "Footer", + "width": "fill_container", + "height": 56, + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv01-hint", + "content": "Connected as @lucaong", + "fontSize": 11, + "fill": "$--muted-foreground" + }, + { + "type": "frame", + "id": "ghv01-btns", + "name": "buttons", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ghv01-cancel", + "name": "cancelBtn", + "padding": [6, 16], + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv01-cancel-lbl", + "content": "Cancel", + "fontSize": 13, + "fill": "$--foreground" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv02", + "x": 700, + "y": 0, + "name": "GitHub Vault — Clone repo list", + "clip": true, + "width": 560, + "height": 540, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv02-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv02-title", + "content": "Connect GitHub Repo", + "fontSize": 16, + "fontWeight": 600, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv02-close", + "name": "closeBtn", + "width": 24, + "height": 24, + "cornerRadius": 4, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ghv02-x", + "content": "X", + "fontSize": 14, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv02-tabs", + "name": "TabBar", + "width": "fill_container", + "height": 44, + "fill": "$--background", + "padding": [0, 24], + "gap": 0, + "alignItems": "end", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv02-tab-clone", + "name": "Tab: Clone Existing (active)", + "padding": [8, 16], + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "bottom": 2 }, + "fill": "$--accent-blue" + }, + "children": [ + { + "type": "text", + "id": "ghv02-tab-clone-lbl", + "content": "Clone Existing", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv02-tab-create", + "name": "Tab: Create New", + "padding": [8, 16], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-tab-create-lbl", + "content": "Create New", + "fontSize": 13, + "fontWeight": 400, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv02-body", + "name": "Body: Repo List", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [16, 24], + "gap": 12, + "children": [ + { + "type": "frame", + "id": "ghv02-search", + "name": "SearchInput (filled)", + "width": "fill_container", + "height": 40, + "cornerRadius": 8, + "fill": "$--input", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--ring" + }, + "padding": [0, 12], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-search-val", + "content": "laputa", + "fontSize": 13, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv02-list", + "name": "RepoList", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "ghv02-repo1", + "name": "RepoItem: selected", + "width": "fill_container", + "height": 56, + "cornerRadius": 6, + "fill": "$--accent", + "padding": [8, 12], + "layout": "vertical", + "gap": 4, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "ghv02-repo1-row", + "name": "topRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-repo1-name", + "content": "lucaong/laputa-vault", + "fontSize": 13, + "fontWeight": 600, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv02-repo1-badge", + "name": "privateBadge", + "padding": [2, 6], + "cornerRadius": 4, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv02-repo1-priv", + "content": "Private", + "fontSize": 10, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "text", + "id": "ghv02-repo1-desc", + "content": "Personal knowledge vault — markdown + YAML frontmatter", + "fontSize": 12, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv02-repo2", + "name": "RepoItem: unselected", + "width": "fill_container", + "height": 56, + "cornerRadius": 6, + "padding": [8, 12], + "layout": "vertical", + "gap": 4, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "ghv02-repo2-row", + "name": "topRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-repo2-name", + "content": "lucaong/laputa-app", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv02-repo2-badge", + "name": "publicBadge", + "padding": [2, 6], + "cornerRadius": 4, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv02-repo2-pub", + "content": "Public", + "fontSize": 10, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "text", + "id": "ghv02-repo2-desc", + "content": "Laputa desktop app — Tauri + React + CodeMirror 6", + "fontSize": 12, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv02-repo3", + "name": "RepoItem: unselected 2", + "width": "fill_container", + "height": 56, + "cornerRadius": 6, + "padding": [8, 12], + "layout": "vertical", + "gap": 4, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "ghv02-repo3-row", + "name": "topRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-repo3-name", + "content": "lucaong/dotfiles", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + } + ] + }, + { + "type": "text", + "id": "ghv02-repo3-desc", + "content": "My macOS dotfiles and config", + "fontSize": 12, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv02-path", + "name": "LocalPathPicker", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "ghv02-path-lbl", + "content": "Clone to:", + "fontSize": 12, + "fontWeight": 500, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv02-path-input", + "name": "pathInput", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "fill": "$--input", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [0, 10], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv02-path-val", + "content": "~/Vaults/laputa-vault", + "fontSize": 12, + "fill": "$--foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv02-footer", + "name": "Footer", + "width": "fill_container", + "height": 56, + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv02-hint", + "content": "3 repos found", + "fontSize": 11, + "fill": "$--muted-foreground" + }, + { + "type": "frame", + "id": "ghv02-btns", + "name": "buttons", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ghv02-cancel", + "name": "cancelBtn", + "padding": [6, 16], + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv02-cancel-lbl", + "content": "Cancel", + "fontSize": 13, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv02-clone-btn", + "name": "cloneBtn", + "padding": [6, 16], + "cornerRadius": 6, + "fill": "$--primary", + "children": [ + { + "type": "text", + "id": "ghv02-clone-lbl", + "content": "Clone & Open", + "fontSize": 13, + "fontWeight": 500, + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv03", + "x": 68, + "y": 600, + "name": "GitHub Vault — Create new repo", + "clip": true, + "width": 560, + "height": 440, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv03-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv03-title", + "content": "Connect GitHub Repo", + "fontSize": 16, + "fontWeight": 600, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv03-close", + "name": "closeBtn", + "width": 24, + "height": 24, + "cornerRadius": 4, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ghv03-x", + "content": "X", + "fontSize": 14, + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv03-tabs", + "name": "TabBar", + "width": "fill_container", + "height": 44, + "fill": "$--background", + "padding": [0, 24], + "gap": 0, + "alignItems": "end", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv03-tab-clone", + "name": "Tab: Clone Existing", + "padding": [8, 16], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv03-tab-clone-lbl", + "content": "Clone Existing", + "fontSize": 13, + "fontWeight": 400, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv03-tab-create", + "name": "Tab: Create New (active)", + "padding": [8, 16], + "alignItems": "center", + "stroke": { + "align": "inside", + "thickness": { "bottom": 2 }, + "fill": "$--accent-blue" + }, + "children": [ + { + "type": "text", + "id": "ghv03-tab-create-lbl", + "content": "Create New", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv03-body", + "name": "Body: Create Form", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [24, 24], + "gap": 20, + "children": [ + { + "type": "frame", + "id": "ghv03-name-field", + "name": "RepoNameField", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "ghv03-name-lbl", + "content": "Repository name", + "fontSize": 12, + "fontWeight": 500, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv03-name-input", + "name": "nameInput", + "width": "fill_container", + "height": 40, + "cornerRadius": 8, + "fill": "$--input", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [0, 12], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv03-name-val", + "content": "my-vault", + "fontSize": 13, + "fill": "$--foreground" + } + ] + }, + { + "type": "text", + "id": "ghv03-name-hint", + "content": "github.com/lucaong/my-vault", + "fontSize": 11, + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv03-vis-field", + "name": "VisibilityToggle", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "ghv03-vis-lbl", + "content": "Visibility", + "fontSize": 12, + "fontWeight": 500, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv03-vis-opts", + "name": "options", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ghv03-opt-priv", + "name": "PrivateOption (selected)", + "padding": [6, 12], + "cornerRadius": 6, + "fill": "$--accent", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--accent-blue" + }, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv03-opt-priv-icon", + "content": "Lock", + "fontSize": 13, + "fill": "$--accent-blue" + }, + { + "type": "text", + "id": "ghv03-opt-priv-lbl", + "content": "Private", + "fontSize": 13, + "fontWeight": 500, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv03-opt-pub", + "name": "PublicOption", + "padding": [6, 12], + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv03-opt-pub-icon", + "content": "Globe", + "fontSize": 13, + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "ghv03-opt-pub-lbl", + "content": "Public", + "fontSize": 13, + "fill": "$--muted-foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv03-path-field", + "name": "LocalPathField", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "ghv03-path-lbl", + "content": "Clone to:", + "fontSize": 12, + "fontWeight": 500, + "fill": "$--foreground" + }, + { + "type": "frame", + "id": "ghv03-path-input", + "name": "pathInput", + "width": "fill_container", + "height": 36, + "cornerRadius": 6, + "fill": "$--input", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "padding": [0, 10], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ghv03-path-val", + "content": "~/Vaults/my-vault", + "fontSize": 12, + "fill": "$--foreground" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv03-footer", + "name": "Footer", + "width": "fill_container", + "height": 56, + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "end", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv03-btns", + "name": "buttons", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ghv03-cancel", + "name": "cancelBtn", + "padding": [6, 16], + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv03-cancel-lbl", + "content": "Cancel", + "fontSize": 13, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv03-create-btn", + "name": "createBtn", + "padding": [6, 16], + "cornerRadius": 6, + "fill": "$--primary", + "children": [ + { + "type": "text", + "id": "ghv03-create-lbl", + "content": "Create & Clone", + "fontSize": 13, + "fontWeight": 500, + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv04", + "x": 700, + "y": 600, + "name": "GitHub Vault — Clone in progress", + "clip": true, + "width": 560, + "height": 320, + "fill": "$--background", + "cornerRadius": 12, + "layout": "vertical", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv04-hdr", + "name": "Header", + "width": "fill_container", + "height": 56, + "fill": "$--background", + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "space-between", + "stroke": { + "align": "inside", + "thickness": { "bottom": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv04-title", + "content": "Cloning Repository...", + "fontSize": 16, + "fontWeight": 600, + "fill": "$--foreground" + } + ] + }, + { + "type": "frame", + "id": "ghv04-body", + "name": "Body: Progress", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [32, 24], + "gap": 20, + "alignItems": "center", + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "ghv04-spinner", + "name": "spinner", + "width": 40, + "height": 40, + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 3, + "fill": "$--accent-blue" + } + }, + { + "type": "text", + "id": "ghv04-status", + "content": "Cloning lucaong/laputa-vault...", + "fontSize": 14, + "fontWeight": 500, + "fill": "$--foreground", + "textAlign": "center" + }, + { + "type": "text", + "id": "ghv04-sub", + "content": "Receiving objects: 67% (1,234/1,842)", + "fontSize": 12, + "fill": "$--muted-foreground", + "textAlign": "center" + }, + { + "type": "frame", + "id": "ghv04-bar-bg", + "name": "progressBarBg", + "width": 360, + "height": 4, + "cornerRadius": 2, + "fill": "$--accent", + "children": [ + { + "type": "frame", + "id": "ghv04-bar-fill", + "name": "progressBarFill", + "width": 241, + "height": 4, + "cornerRadius": 2, + "fill": "$--accent-blue" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ghv04-footer", + "name": "Footer", + "width": "fill_container", + "height": 56, + "padding": [0, 24], + "alignItems": "center", + "justifyContent": "end", + "stroke": { + "align": "inside", + "thickness": { "top": 1 }, + "fill": "$--border" + }, + "children": [ + { + "type": "frame", + "id": "ghv04-cancel", + "name": "cancelBtn", + "padding": [6, 16], + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "children": [ + { + "type": "text", + "id": "ghv04-cancel-lbl", + "content": "Cancel", + "fontSize": 13, + "fill": "$--foreground" + } + ] + } + ] + } + ] + } + ], + "variables": { + "--accent": { + "type": "color", + "value": [{ "value": "#EBEBEA" }, { "value": "#252545", "theme": { "Mode": "Dark" } }] + }, + "--accent-blue": { + "type": "color", + "value": [{ "value": "#2383E2" }, { "value": "#4a9eff", "theme": { "Mode": "Dark" } }] + }, + "--background": { + "type": "color", + "value": [{ "value": "#FFFFFF" }, { "value": "#0f0f1a", "theme": { "Mode": "Dark" } }] + }, + "--border": { + "type": "color", + "value": [{ "value": "#E9E9E7" }, { "value": "#2a2a4a", "theme": { "Mode": "Dark" } }] + }, + "--foreground": { + "type": "color", + "value": [{ "value": "#37352F" }, { "value": "#e0e0e0", "theme": { "Mode": "Dark" } }] + }, + "--input": { + "type": "color", + "value": [{ "value": "#F7F6F3" }, { "value": "#1a1a30", "theme": { "Mode": "Dark" } }] + }, + "--muted-foreground": { + "type": "color", + "value": [{ "value": "#787774" }, { "value": "#9090a0", "theme": { "Mode": "Dark" } }] + }, + "--primary": { + "type": "color", + "value": [{ "value": "#2383E2" }, { "value": "#4a9eff", "theme": { "Mode": "Dark" } }] + }, + "--ring": { + "type": "color", + "value": [{ "value": "#2383E2" }, { "value": "#4a9eff", "theme": { "Mode": "Dark" } }] + } + } +} \ No newline at end of file diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs new file mode 100644 index 00000000..c66938e2 --- /dev/null +++ b/src-tauri/src/github.rs @@ -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, + pub private: bool, + pub clone_url: String, + pub html_url: String, + pub updated_at: Option, +} + +/// Lists the authenticated user's GitHub repositories. +pub async fn github_list_repos(token: &str) -> Result, String> { + let client = reqwest::Client::new(); + let mut all_repos: Vec = 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 = 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, + private: bool, + clone_url: String, + html_url: String, + updated_at: Option, +} + +/// Creates a new GitHub repository for the authenticated user. +pub async fn github_create_repo( + token: &str, + name: &str, + private: bool, +) -> Result { + 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 { + 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 { + 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); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7278a68d..1558191c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, String> { + github::github_list_repos(&token).await +} + +#[tauri::command] +async fn github_create_repo(token: String, name: String, private: bool) -> Result { + github::github_create_repo(&token, &name, private).await +} + +#[tauri::command] +fn clone_repo(url: String, token: String, local_path: String) -> Result { + 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"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index a09208a5..b12e11d9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -7,6 +7,7 @@ pub struct Settings { pub anthropic_key: Option, pub openai_key: Option, pub google_key: Option, + pub github_token: Option, } fn settings_path() -> Result { @@ -15,19 +16,17 @@ fn settings_path() -> Result { .ok_or_else(|| "Could not determine config directory".to_string()) } -pub fn get_settings() -> Result { - let path = settings_path()?; +fn get_settings_at(path: &PathBuf) -> Result { 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 { + 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: 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")); } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 8d213945..1c77c86d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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 }), diff --git a/src/App.tsx b/src/App.tsx index 08b03fc8..ba0aa874 100644 --- a/src/App.tsx +++ b/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(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([]) + + 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() { /> - setShowSettings(true)} /> + setShowSettings(true)} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} /> setToastMessage(null)} /> setShowQuickOpen(false)} /> setShowCreateTypeDialog(false)} onCreate={handleCreateType} /> setShowCommitDialog(false)} /> setShowSettings(false)} /> + setShowGitHubVault(false)} + onVaultCloned={handleVaultCloned} + onOpenSettings={() => { setShowGitHubVault(false); setShowSettings(true) }} + /> ) } diff --git a/src/components/GitHubVaultModal.test.tsx b/src/components/GitHubVaultModal.test.tsx new file mode 100644 index 00000000..5d9da6e7 --- /dev/null +++ b/src/components/GitHubVaultModal.test.tsx @@ -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( + + ) + expect(container.querySelector('[data-testid="github-vault-modal"]')).not.toBeInTheDocument() + }) + + it('shows connect prompt when no GitHub token', () => { + render( + + ) + 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( + + ) + fireEvent.click(screen.getByTestId('github-open-settings')) + expect(onOpenSettings).toHaveBeenCalled() + }) + + it('shows clone and create tabs when token is present', async () => { + render( + + ) + expect(screen.getByTestId('github-tab-clone')).toBeInTheDocument() + expect(screen.getByTestId('github-tab-create')).toBeInTheDocument() + }) + + it('loads and displays repos in clone tab', async () => { + render( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + await waitFor(() => { + expect(screen.getByText('user/my-vault')).toBeInTheDocument() + }) + + expect(screen.getByText('Private')).toBeInTheDocument() + expect(screen.getByText('Public')).toBeInTheDocument() + }) +}) diff --git a/src/components/GitHubVaultModal.tsx b/src/components/GitHubVaultModal.tsx new file mode 100644 index 00000000..9ebb2330 --- /dev/null +++ b/src/components/GitHubVaultModal.tsx @@ -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(cmd: string, args: Record): Promise { + return isTauri() ? invoke(cmd, args) : mockInvoke(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 ( +
{ 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}`} + > +
+ {repo.full_name} + + {repo.private ? 'Private' : 'Public'} + +
+ {repo.description && ( + {repo.description} + )} +
+ ) +} + +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 ( +
+ setSearch(e.target.value)} + data-testid="github-repo-search" + /> + +
+ {loading ? ( +
+ Loading repositories... +
+ ) : filtered.length === 0 ? ( +
+ {search ? 'No repos match your search' : 'No repositories found'} +
+ ) : ( +
+ {filtered.map(repo => ( + setSelectedRepo(repo)} + /> + ))} +
+ )} +
+ +
+ + setLocalPath(e.target.value)} + placeholder="~/Vaults/repo-name" + data-testid="github-clone-path" + /> +
+ + {cloneError && ( +

{cloneError}

+ )} + + + + {filtered.length} repo{filtered.length !== 1 ? 's' : ''} found + + + +
+ ) +} + +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 ( +
+
+ + setRepoName(e.target.value)} + placeholder="my-vault" + data-testid="github-repo-name" + /> + {repoName && ( + + github.com/you/{repoName} + + )} +
+ +
+ +
+ + +
+
+ +
+ + setLocalPath(e.target.value)} + placeholder="~/Vaults/my-vault" + data-testid="github-create-path" + /> +
+ + {cloneError && ( +

{cloneError}

+ )} + + + + +
+ ) +} + +function CloningProgress({ repoName }: { repoName: string }) { + return ( +
+
+

Cloning {repoName}...

+

This may take a moment for large repositories

+
+ ) +} + +export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings }: GitHubVaultModalProps) { + const [tab, setTab] = useState('clone') + const [repos, setRepos] = useState([]) + const [loading, setLoading] = useState(false) + const [search, setSearch] = useState('') + const [selectedRepo, setSelectedRepo] = useState(null) + const [clonePath, setClonePath] = useState('') + const [repoName, setRepoName] = useState('') + const [isPrivate, setIsPrivate] = useState(true) + const [createPath, setCreatePath] = useState('') + const [cloneStatus, setCloneStatus] = useState('idle') + const [cloneError, setCloneError] = useState(null) + const loadedRef = useRef(false) + + const loadRepos = useCallback(async () => { + if (!githubToken) return + setLoading(true) + try { + const result = await tauriCall('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('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('github_create_repo', { + token: githubToken, + name: repoName.trim(), + private: isPrivate, + }) + await tauriCall('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 ( + { if (!isOpen) handleClose() }}> + + + Connect GitHub Repo + Connect your GitHub account to clone or create vaults backed by GitHub repos. + +
+

+ You need to connect your GitHub account first. Add your GitHub token in Settings. +

+ +
+
+
+ ) + } + + const cloningRepoName = tab === 'clone' ? (selectedRepo?.full_name ?? '') : repoName + + return ( + { if (!isOpen) handleClose() }}> + + + Connect GitHub Repo + Clone an existing repo or create a new one as a vault. + + + {cloneStatus === 'cloning' ? ( + + ) : ( + + + Clone Existing + Create New + + + + + + + + + + + )} + + + ) +} diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index e669428b..5be9fc8e 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -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, }) }) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 0b9ffd0b..e77cc1d7 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -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('')} /> + +
+ +
+
+ GitHub +
+
+ Personal access token for cloning and syncing vaults with GitHub. +
+
+ + setGithubToken('')} + />
{/* Footer */} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index a1972b0d..9ada3e94 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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(null) const activeVault = vaults.find((v) => v.path === vaultPath) @@ -53,8 +55,28 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault }: { vaults: VaultOption[] {activeVault?.label ?? 'Vault'} {open && ( -
+
{vaults.map((v) => { onSwitchVault(v.path); setOpen(false) }} />)} + {onConnectGitHub && ( + <> +
+
{ 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" + > + + Connect GitHub repo +
+ + )}
)}
@@ -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 (