From e2745d96eb309610df915c085c94a3f8d6bdcec5 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 12 Apr 2026 17:08:07 +0200 Subject: [PATCH] refactor: remove github auth integration --- docs/ABSTRACTIONS.md | 21 +- docs/ARCHITECTURE.md | 60 +-- docs/GETTING-STARTED.md | 16 +- ...6-system-git-cli-auth-no-provider-oauth.md | 50 ++ docs/adr/README.md | 3 +- src-tauri/Cargo.lock | 178 +------ src-tauri/Cargo.toml | 2 - src-tauri/src/commands/git.rs | 13 + src-tauri/src/commands/github.rs | 88 ---- src-tauri/src/commands/mod.rs | 21 +- src-tauri/src/git/clone.rs | 163 +++++++ src-tauri/src/git/mod.rs | 97 ++-- src-tauri/src/github/api.rs | 326 ------------- src-tauri/src/github/auth.rs | 242 ---------- src-tauri/src/github/clone.rs | 324 ------------- src-tauri/src/github/mod.rs | 139 ------ src-tauri/src/lib.rs | 6 - src-tauri/src/settings.rs | 165 +++---- src-tauri/src/vault/getting_started.rs | 2 +- src/App.test.tsx | 2 +- src/App.tsx | 13 +- src/components/CloneVaultModal.test.tsx | 85 ++++ src/components/CloneVaultModal.tsx | 204 ++++++++ src/components/GitHubDeviceFlow.tsx | 226 --------- src/components/GitHubVaultModal.test.tsx | 218 --------- src/components/GitHubVaultModal.tsx | 444 ------------------ src/components/SettingsPanel.test.tsx | 221 +-------- src/components/SettingsPanel.tsx | 90 +--- src/components/StatusBar.test.tsx | 4 +- src/components/StatusBar.tsx | 9 +- src/components/WelcomeScreen.tsx | 6 +- .../status-bar/StatusBarSections.tsx | 9 +- src/components/status-bar/VaultMenu.tsx | 23 +- src/hooks/useDialogs.ts | 8 +- src/hooks/useSettings.test.ts | 10 +- src/hooks/useSettings.ts | 2 - src/hooks/useTelemetry.test.ts | 2 +- src/mock-tauri/mock-handlers.ts | 36 +- src/types.ts | 32 -- tests/helpers/fixtureVault.ts | 242 +++++----- 40 files changed, 873 insertions(+), 2929 deletions(-) create mode 100644 docs/adr/0056-system-git-cli-auth-no-provider-oauth.md delete mode 100644 src-tauri/src/commands/github.rs create mode 100644 src-tauri/src/git/clone.rs delete mode 100644 src-tauri/src/github/api.rs delete mode 100644 src-tauri/src/github/auth.rs delete mode 100644 src-tauri/src/github/clone.rs delete mode 100644 src-tauri/src/github/mod.rs create mode 100644 src/components/CloneVaultModal.test.tsx create mode 100644 src/components/CloneVaultModal.tsx delete mode 100644 src/components/GitHubDeviceFlow.tsx delete mode 100644 src/components/GitHubVaultModal.test.tsx delete mode 100644 src/components/GitHubVaultModal.tsx diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 0849165b..b23629fc 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -514,13 +514,13 @@ Per-vault settings stored locally and scoped by vault path: - User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder - Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback) -### GitHub Integration +### Remote Git Operations -Device Authorization Flow for GitHub-backed vaults: -- `GitHubDeviceFlow` component handles OAuth -- `GitHubVaultModal` for cloning existing repos or creating new ones -- Token persisted in app settings for future git operations -- `SettingsPanel` shows connection status with disconnect option +Tolaria delegates remote auth to the user's system git setup: +- `CloneVaultModal` captures a remote URL and local destination +- `clone_repo` shells out to system git for clone operations +- Existing `git_pull` / `git_push` commands keep surfacing raw git errors +- No provider-specific token or username is stored in app settings ## Settings @@ -528,11 +528,12 @@ App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads ```typescript interface Settings { - openai_key: string | null - google_key: string | null - github_token: string | null - github_username: string | null auto_pull_interval_minutes: number | null + telemetry_consent: boolean | null + crash_reporting_enabled: boolean | null + analytics_enabled: boolean | null + anonymous_id: string | null + release_channel: string | null } ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3cc25db6..762e7e7b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,7 +22,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this |-------------------|-----------------------------| | Type icon, type color | Editor zoom level | | Pinned properties per type | API keys (OpenAI, Google) | -| Sidebar label overrides | GitHub token | +| Sidebar label overrides | Auto-sync interval | | Property display order | Window size / position | | Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting | @@ -124,11 +124,10 @@ flowchart TD end subgraph RB["Rust Backend"] - LIB["lib.rs → 64 Tauri commands"] + LIB["lib.rs → Tauri commands"] VAULT["vault/"] FM["frontmatter/"] - GIT["git/"] - GH["github/"] + GIT["git/\n(commit, sync, clone)"] SETTINGS["settings.rs"] SEARCH["search.rs"] CLI["claude_cli.rs"] @@ -137,11 +136,15 @@ flowchart TD subgraph EXT["External Services"] CCLI["Claude CLI\n(agent subprocess)"] MCP["MCP Server\n(ws://9710, 9711)"] - GHAPI["GitHub API\n(OAuth, repos, clone)"] + GCLI["git CLI\n(system executable)"] + REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"] end FE -->|"Tauri IPC"| RB - FE -->|"Vite Proxy / WS"| EXT + CLI -->|"spawn subprocess"| CCLI + LIB -->|"register / monitor"| MCP + GIT -->|"clone / fetch / push / pull"| GCLI + GCLI -->|"network auth via user config"| REMOTE end style FE fill:#e8f4fd,stroke:#2196f3,color:#000 @@ -429,23 +432,23 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it - **Open an existing folder** → system file picker - **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime -The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public GitHub URL and delegates the actual clone to the existing git backend. +The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public starter repo URL and delegates the actual clone to the git backend. -### GitHub OAuth Integration +### Remote Clone & Auth Model -Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed vaults. +Tolaria no longer implements provider-specific OAuth or remote-repository APIs. All remote git work goes through the user's existing system git configuration. **Flow:** -1. User clicks "Login with GitHub" in Settings panel -2. `github_device_flow_start()` returns a user code + verification URL -3. User authorizes at `github.com/login/device` -4. App polls `github_device_flow_poll()` until authorized -5. Token stored in `~/.config/com.tolaria.app/settings.json` +1. User opens `CloneVaultModal` from onboarding or the vault menu +2. User pastes any git URL and chooses a local destination +3. `clone_repo()` shells out to `git clone` +4. `git_push()` / `git_pull()` continue to use the same system git path +5. If auth fails, the raw git stderr is surfaced in the UI -**Vault operations:** -- `GitHubVaultModal`: Clone existing repo or create new private/public repo -- `clone_repo()`: Clones with token-injected HTTPS URL -- Token persists for future git push/pull operations +**Auth model:** +- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup +- No provider tokens are stored in Tolaria settings +- The same flow works for GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes ## Pulse View @@ -565,8 +568,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: |--------|---------| | `vault/` | Vault scanning, caching, parsing, rename, image, migration | | `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) | -| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) | -| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) | +| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`) | | `search.rs` | Keyword search — walkdir-based vault file scan | | `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing | | `mcp.rs` | MCP server spawning + config registration | @@ -576,7 +578,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `vault_list.rs` | Vault list persistence | | `menu.rs` | Native macOS menu bar | -## Tauri IPC Commands (65 total) +## Tauri IPC Commands ### Vault Operations @@ -620,17 +622,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `get_conflict_mode` | Get conflict resolution mode | | `get_vault_pulse` | Git activity feed (paginated) | | `get_last_commit_info` | Latest commit metadata | - -### GitHub - -| Command | Description | -|---------|-------------| -| `github_device_flow_start` | Begin OAuth device flow | -| `github_device_flow_poll` | Poll for authorization | -| `github_get_user` | Get authenticated user info | -| `github_list_repos` | List user's repos | -| `github_create_repo` | Create new repo | -| `clone_repo` | Clone repo with token auth | +| `clone_repo` | Clone a remote repository into a local folder using system git | ### Search @@ -705,7 +697,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation | | `useAutoSync` | Sync interval, pull/push state | Git auto-sync | | `useUnifiedSearch` | Query, results, loading state | Keyword search | -| `useSettings` | App settings (API keys, GitHub token) | Persistent settings | +| `useSettings` | App settings (telemetry, release channel, auto-sync interval) | Persistent settings | | `useVaultConfig` | Per-vault UI preferences | Vault-specific config | | `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands | @@ -868,7 +860,7 @@ Desktop-only modules gated at the crate level: Desktop-only features gated at the function level in `commands/`: - Git operations (commit, pull, push, status, history, diff, conflicts) -- GitHub operations (clone, list repos, device flow auth) +- Clone-by-URL via system git (`clone_repo`) - Claude CLI streaming (check, chat, agent) - MCP registration and status - Menu state updates diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index ecbbfad2..c286028b 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -63,8 +63,7 @@ tolaria/ │ │ ├── CommandPalette.tsx # Cmd+K command launcher │ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions │ │ ├── WelcomeScreen.tsx # Onboarding screen -│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create -│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow +│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL │ │ ├── ConflictResolverModal.tsx # Git conflict resolution │ │ ├── CommitDialog.tsx # Git commit modal │ │ ├── CreateNoteDialog.tsx # New note modal @@ -134,7 +133,7 @@ tolaria/ │ ├── capabilities/ # Tauri v2 security capabilities │ ├── src/ │ │ ├── main.rs # Entry point (calls lib::run()) -│ │ ├── lib.rs # Tauri setup + command registration (61 commands) +│ │ ├── lib.rs # Tauri setup + command registration │ │ ├── commands/ # Tauri command handlers (split into modules) │ │ ├── vault/ # Vault module │ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault @@ -147,10 +146,8 @@ tolaria/ │ │ ├── frontmatter/ # Frontmatter module │ │ │ ├── mod.rs, yaml.rs, ops.rs │ │ ├── git/ # Git module -│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs +│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs │ │ │ ├── conflict.rs, remote.rs, pulse.rs -│ │ ├── github/ # GitHub module -│ │ │ ├── mod.rs, auth.rs, api.rs, clone.rs │ │ ├── telemetry.rs # Sentry init + path scrubber │ │ ├── search.rs # Keyword search (walkdir-based) │ │ ├── claude_cli.rs # Claude CLI subprocess management @@ -212,8 +209,7 @@ tolaria/ | `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. | | `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. | | `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. | -| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). | -| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. | +| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse). | | `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. | | `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. | @@ -245,9 +241,9 @@ tolaria/ | File | Why it matters | |------|---------------| -| `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). | +| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, auto-sync interval). | | `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). | -| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection and the vault-level explicit organization toggle. | +| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, and the vault-level explicit organization toggle. | ## Architecture Patterns diff --git a/docs/adr/0056-system-git-cli-auth-no-provider-oauth.md b/docs/adr/0056-system-git-cli-auth-no-provider-oauth.md new file mode 100644 index 00000000..a1d0a8b9 --- /dev/null +++ b/docs/adr/0056-system-git-cli-auth-no-provider-oauth.md @@ -0,0 +1,50 @@ +--- +type: ADR +id: "0056" +title: "System git auth only — no provider-specific OAuth or repo APIs" +status: active +date: 2026-04-12 +supersedes: "0019" +--- + +## Context + +Tolaria already uses the system `git` executable for the core remote workflow: commit, pull, push, status, history, and conflict resolution. The only provider-specific part left was GitHub authentication and repository management: + +- GitHub Device Flow OAuth +- persisted `github_token` / `github_username` settings +- GitHub-only clone/create UI +- GitHub API calls for repo listing and creation + +That split made the product more complex than the actual user need. Tolaria's remote-sync users are developers who typically already have git configured via SSH keys, Git Credential Manager, Keychain helpers, or `gh auth`. The app was carrying a provider-specific auth stack even though the real transport path was already plain git CLI. + +## Decision + +**Tolaria does not implement provider-specific authentication or remote-repository APIs. All remote auth is delegated to the user's existing system git configuration, and cloning is a generic "paste any git URL" flow.** + +Concretely: + +- remove GitHub Device Flow commands and UI +- remove persisted GitHub auth fields from app settings +- remove GitHub repo list/create API integration +- keep `clone_repo`, but make it a generic system-git clone command +- keep commit / pull / push behavior unchanged apart from surfacing raw git errors directly + +## Options considered + +- **Option A — Keep GitHub Device Flow OAuth** (ADR-0019, now superseded): polished GitHub-specific onboarding, but it preserves provider lock-in, token storage, and an entire second auth model beside system git. +- **Option B — Replace OAuth with manual PAT entry**: smaller implementation than Device Flow, but still provider-specific, still stores credentials in app settings, and still teaches users the wrong abstraction. +- **Option C — Pure system git auth** (chosen): one auth path, less code, works with any git host, and aligns the clone flow with the rest of Tolaria's git stack. Downside: users must already have git auth configured outside the app. + +## Consequences + +- `CloneVaultModal` accepts any git URL and local destination path. +- `clone_repo` shells out to system git without injecting provider tokens. +- `git_push` / `git_pull` continue to rely on the same external git configuration; auth failures surface as raw git stderr. +- `SettingsPanel` no longer contains a GitHub connection section. +- Tolaria no longer stores git-provider credentials in `settings.json`. +- GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes all work through the same product path. +- Creating or listing remote repos from inside Tolaria is no longer supported; remote setup happens in the user's normal git tools. +- The Getting Started vault still clones from a public remote URL, but it now goes through the same generic git clone path as every other vault import. + +Re-evaluate if Tolaria later targets less technical users who cannot reasonably be expected to configure git outside the app. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2f6c0223..7a4cd49d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -74,7 +74,7 @@ proposed → active → superseded | [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active | | [0017](0017-canary-release-channel.md) | Canary release channel and feature flags | active | | [0018](0018-codescene-code-health-gates.md) | CodeScene code health gates in CI | active | -| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | active | +| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | superseded → [0056](0056-system-git-cli-auth-no-provider-oauth.md) | | [0020](0020-keyboard-first-design.md) | Keyboard-first design principle | active | | [0021](0021-push-to-main-workflow.md) | Push directly to main (no PRs) | active | | [0022](0022-blocknote-rich-text-editor.md) | BlockNote as the rich text editor | active | @@ -111,3 +111,4 @@ proposed → active → superseded | [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active | | [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active | | [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active | +| [0056](0056-system-git-cli-auth-no-provider-oauth.md) | System git auth only — no provider-specific OAuth or repo APIs | active | diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5cb35576..253c8067 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -123,16 +123,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -619,15 +609,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "combine" version = "4.6.7" @@ -663,16 +644,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -696,7 +667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-graphics-types", "foreign-types 0.5.0", "libc", @@ -709,7 +680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "libc", ] @@ -1701,25 +1672,6 @@ dependencies = [ "syn 2.0.115", ] -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.13.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -1871,11 +1823,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1934,11 +1884,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -2449,31 +2397,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mockito" -version = "1.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" -dependencies = [ - "assert-json-diff", - "bytes", - "colored", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "log", - "pin-project-lite", - "rand 0.9.2", - "regex", - "serde_json", - "serde_urlencoded", - "similar", - "tokio", -] - [[package]] name = "muda" version = "0.17.1" @@ -3434,16 +3357,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand_chacha" version = "0.2.2" @@ -3464,16 +3377,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.5.1" @@ -3492,15 +3395,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_hc" version = "0.2.0" @@ -3631,21 +3525,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", "log", - "mime", "native-tls", "percent-encoding", "pin-project-lite", @@ -3656,14 +3546,12 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", "web-sys", ] @@ -3702,7 +3590,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -3858,7 +3746,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "jni", "log", @@ -3990,7 +3878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -4364,12 +4252,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "siphasher" version = "0.3.11" @@ -4569,27 +4451,6 @@ dependencies = [ "syn 2.0.115", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "system-deps" version = "6.2.2" @@ -4611,7 +4472,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation 0.10.1", + "core-foundation", "core-graphics", "crossbeam-channel", "dispatch", @@ -5182,7 +5043,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "socket2", "tokio-macros", @@ -5243,9 +5103,7 @@ dependencies = [ "futures-util", "gray_matter", "log", - "mockito", "regex", - "reqwest 0.12.28", "sentry", "serde", "serde_json", @@ -5834,19 +5692,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -6120,17 +5965,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.3.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 601cad2d..fe06d11a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,7 +25,6 @@ tauri-plugin-log = "2" gray_matter = "0.2" walkdir = "2" chrono = { version = "0.4", features = ["serde"] } -reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } futures-util = "0.3" base64 = "0.22" @@ -41,4 +40,3 @@ uuid = { version = "1", features = ["v4"] } [dev-dependencies] tempfile = "3" -mockito = "1" diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs index 290e6f83..fc0bc20a 100644 --- a/src-tauri/src/commands/git.rs +++ b/src-tauri/src/commands/git.rs @@ -151,6 +151,13 @@ pub fn init_git_repo(vault_path: String) -> Result<(), String> { crate::git::init_repo(&vault_path) } +#[cfg(desktop)] +#[tauri::command] +pub fn clone_repo(url: String, local_path: String) -> Result { + let local_path = expand_tilde(&local_path); + crate::git::clone_repo(&url, &local_path) +} + // ── Git commands (mobile stubs) ───────────────────────────────────────────── #[cfg(mobile)] @@ -271,3 +278,9 @@ pub fn is_git_repo(_vault_path: String) -> bool { pub fn init_git_repo(_vault_path: String) -> Result<(), String> { Err("Git init is not available on mobile".into()) } + +#[cfg(mobile)] +#[tauri::command] +pub fn clone_repo(_url: String, _local_path: String) -> Result { + Err("Git clone is not available on mobile".into()) +} diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs deleted file mode 100644 index 3f09a6f7..00000000 --- a/src-tauri/src/commands/github.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo}; - -use super::expand_tilde; - -// ── GitHub commands (desktop) ─────────────────────────────────────────────── - -#[cfg(desktop)] -#[tauri::command] -pub async fn github_list_repos(token: String) -> Result, String> { - crate::github::github_list_repos(&token).await -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn github_create_repo( - token: String, - name: String, - private: bool, -) -> Result { - crate::github::github_create_repo(&token, &name, private).await -} - -#[cfg(desktop)] -#[tauri::command] -pub fn clone_repo(url: String, token: String, local_path: String) -> Result { - let local_path = expand_tilde(&local_path); - crate::github::clone_repo(&url, &token, &local_path) -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn github_device_flow_start() -> Result { - crate::github::github_device_flow_start().await -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn github_device_flow_poll(device_code: String) -> Result { - crate::github::github_device_flow_poll(&device_code).await -} - -#[cfg(desktop)] -#[tauri::command] -pub async fn github_get_user(token: String) -> Result { - crate::github::github_get_user(&token).await -} - -// ── GitHub commands (mobile stubs) ────────────────────────────────────────── - -#[cfg(mobile)] -#[tauri::command] -pub async fn github_list_repos(_token: String) -> Result, String> { - Err("GitHub integration is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn github_create_repo( - _token: String, - _name: String, - _private: bool, -) -> Result { - Err("GitHub integration is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub fn clone_repo(_url: String, _token: String, _local_path: String) -> Result { - Err("Git clone is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn github_device_flow_start() -> Result { - Err("GitHub integration is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn github_device_flow_poll(_device_code: String) -> Result { - Err("GitHub integration is not available on mobile".into()) -} - -#[cfg(mobile)] -#[tauri::command] -pub async fn github_get_user(_token: String) -> Result { - Err("GitHub integration is not available on mobile".into()) -} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 227405b2..26406091 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,5 @@ mod ai; mod git; -mod github; mod system; mod vault; @@ -8,7 +7,6 @@ use std::borrow::Cow; pub use ai::*; pub use git::*; -pub use github::*; pub use system::*; pub use vault::*; @@ -16,16 +14,17 @@ pub use vault::*; /// Returns the original string unchanged if it doesn't start with `~` or if the /// home directory cannot be determined. pub fn expand_tilde(path: &str) -> Cow<'_, str> { - if path == "~" { - if let Some(home) = dirs::home_dir() { - return Cow::Owned(home.to_string_lossy().into_owned()); - } - } else if let Some(rest) = path.strip_prefix("~/") { - if let Some(home) = dirs::home_dir() { - return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest)); - } + let Some(home) = dirs::home_dir() else { + return Cow::Borrowed(path); + }; + + match path { + "~" => Cow::Owned(home.to_string_lossy().into_owned()), + _ => path + .strip_prefix("~/") + .map(|rest| Cow::Owned(home.join(rest).to_string_lossy().into_owned())) + .unwrap_or(Cow::Borrowed(path)), } - Cow::Borrowed(path) } pub fn parse_build_label(version: &str) -> String { diff --git a/src-tauri/src/git/clone.rs b/src-tauri/src/git/clone.rs new file mode 100644 index 00000000..9158af55 --- /dev/null +++ b/src-tauri/src/git/clone.rs @@ -0,0 +1,163 @@ +use std::path::Path; +use std::process::Command; + +/// Clone a git repository to a local path using the system git configuration. +pub fn clone_repo(url: &str, local_path: &str) -> Result { + let dest = Path::new(local_path); + prepare_clone_destination(dest)?; + + if let Err(err) = run_clone(url, dest) { + cleanup_failed_clone(dest); + return Err(err); + } + + Ok(format!("Cloned to {}", dest.display())) +} + +fn prepare_clone_destination(dest: &Path) -> Result<(), String> { + if !dest.exists() { + return ensure_parent_directory(dest); + } + + ensure_empty_directory(dest) +} + +fn ensure_empty_directory(dest: &Path) -> Result<(), String> { + if !dest.is_dir() { + return Err(format!( + "Destination '{}' already exists and is not a directory", + dest.display() + )); + } + + if directory_has_entries(dest)? { + return Err(format!( + "Destination '{}' already exists and is not empty", + dest.display() + )); + } + + Ok(()) +} + +fn ensure_parent_directory(dest: &Path) -> Result<(), String> { + let Some(parent) = dest.parent() else { + return Ok(()); + }; + + if parent.as_os_str().is_empty() { + return Ok(()); + } + + std::fs::create_dir_all(parent).map_err(|e| { + format!( + "Failed to create parent directory for '{}': {}", + dest.display(), + e + ) + }) +} + +fn directory_has_entries(dest: &Path) -> Result { + dest.read_dir() + .map_err(|e| format!("Failed to inspect destination '{}': {}", dest.display(), e)) + .map(|mut entries| entries.next().is_some()) +} + +fn run_clone(url: &str, dest: &Path) -> Result<(), String> { + let destination = dest + .to_str() + .ok_or_else(|| format!("Destination '{}' is not valid UTF-8", dest.display()))?; + let output = Command::new("git") + .args(["clone", "--progress", url, destination]) + .output() + .map_err(|e| format!("Failed to run git clone: {}", e))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("git clone failed: {}", stderr.trim())) +} + +fn cleanup_failed_clone(dest: &Path) { + if dest.exists() && dest.is_dir() { + let _ = std::fs::remove_dir_all(dest); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + use std::process::Command as StdCommand; + + fn init_source_repo(path: &Path) { + fs::create_dir_all(path).unwrap(); + fs::write(path.join("welcome.md"), "# Welcome\n").unwrap(); + + StdCommand::new("git") + .args(["init"]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.email", "tolaria@app.local"]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.name", "Tolaria App"]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "Initial commit"]) + .current_dir(path) + .output() + .unwrap(); + } + + #[test] + fn test_clone_repo_clones_local_repository() { + let dir = tempfile::TempDir::new().unwrap(); + let source = dir.path().join("source"); + let dest = dir.path().join("dest"); + init_source_repo(&source); + + let result = clone_repo(source.to_str().unwrap(), dest.to_str().unwrap()).unwrap(); + + assert_eq!(result, format!("Cloned to {}", dest.to_string_lossy())); + assert!(dest.join(".git").exists()); + assert!(dest.join("welcome.md").exists()); + } + + #[test] + fn test_clone_repo_nonempty_dest() { + let dir = tempfile::TempDir::new().unwrap(); + fs::write(dir.path().join("existing.txt"), "data").unwrap(); + + let result = clone_repo("https://example.com/repo.git", dir.path().to_str().unwrap()); + assert!(result.unwrap_err().contains("not empty")); + } + + #[test] + fn test_clone_repo_empty_dest_allowed() { + let dir = tempfile::TempDir::new().unwrap(); + let dest = dir.path().join("empty-dir"); + fs::create_dir(&dest).unwrap(); + + let result = clone_repo( + "https://example.com/nonexistent/repo.git", + dest.to_str().unwrap(), + ); + assert!(result.unwrap_err().contains("git clone failed")); + } +} diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index 2d47a1ca..fbaadb6c 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -1,3 +1,4 @@ +mod clone; mod commit; mod conflict; mod dates; @@ -9,6 +10,7 @@ mod status; use std::path::Path; use std::process::Command; +pub use clone::clone_repo; pub use commit::git_commit; pub use conflict::{ get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict, @@ -122,28 +124,27 @@ fn ensure_author_config(dir: &Path) -> Result<(), String> { /// Extract "owner/repo" from a GitHub remote URL. /// Supports HTTPS (https://github.com/owner/repo.git) and /// SSH (git@github.com:owner/repo.git) formats. +fn normalize_github_repo_path(repo_path: &str) -> Option { + let repo_path = repo_path.strip_suffix(".git").unwrap_or(repo_path); + repo_path.contains('/').then(|| repo_path.to_string()) +} + +fn github_remote_suffix(url: &str) -> Option<&str> { + const GITHUB_PREFIXES: [&str; 4] = [ + "git@github.com:", + "https://github.com/", + "http://github.com/", + "ssh://git@github.com/", + ]; + + GITHUB_PREFIXES + .iter() + .find_map(|prefix| url.strip_prefix(prefix)) + .or_else(|| url.split_once("@github.com/").map(|(_, suffix)| suffix)) +} + fn parse_github_repo_path(url: &str) -> Option { - let trimmed = url.trim(); - - // SSH format: git@github.com:owner/repo.git - if let Some(rest) = trimmed.strip_prefix("git@github.com:") { - let path = rest.strip_suffix(".git").unwrap_or(rest); - if path.contains('/') { - return Some(path.to_string()); - } - } - - // HTTPS format: https://github.com/owner/repo.git - // Also handle token-embedded URLs: https://token@github.com/owner/repo.git - if trimmed.contains("github.com/") { - let after = trimmed.split("github.com/").nth(1)?; - let path = after.strip_suffix(".git").unwrap_or(after); - if path.contains('/') { - return Some(path.to_string()); - } - } - - None + github_remote_suffix(url.trim()).and_then(normalize_github_repo_path) } #[cfg(test)] @@ -153,6 +154,13 @@ mod tests { use std::process::Command; use tempfile::TempDir; + fn assert_repo_path(url: &str, expected: Option<&str>) { + assert_eq!( + parse_github_repo_path(url), + expected.map(ToString::to_string) + ); + } + pub(crate) fn setup_git_repo() -> TempDir { let dir = TempDir::new().unwrap(); let path = dir.path(); @@ -348,42 +356,23 @@ mod tests { } #[test] - fn test_parse_github_repo_path_https() { - assert_eq!( - parse_github_repo_path("https://github.com/owner/repo.git"), - Some("owner/repo".to_string()) - ); - assert_eq!( - parse_github_repo_path("https://github.com/owner/repo"), - Some("owner/repo".to_string()) - ); - } - - #[test] - fn test_parse_github_repo_path_ssh() { - assert_eq!( - parse_github_repo_path("git@github.com:owner/repo.git"), - Some("owner/repo".to_string()) - ); - assert_eq!( - parse_github_repo_path("git@github.com:owner/repo"), - Some("owner/repo".to_string()) - ); - } - - #[test] - fn test_parse_github_repo_path_token_embedded() { - assert_eq!( - parse_github_repo_path("https://gho_abc123@github.com/owner/repo.git"), - Some("owner/repo".to_string()) - ); + fn test_parse_github_repo_path_variants() { + for url in [ + "https://github.com/owner/repo.git", + "https://github.com/owner/repo", + "http://github.com/owner/repo.git", + "git@github.com:owner/repo.git", + "git@github.com:owner/repo", + "ssh://git@github.com/owner/repo.git", + "https://gho_abc123@github.com/owner/repo.git", + ] { + assert_repo_path(url, Some("owner/repo")); + } } #[test] fn test_parse_github_repo_path_non_github() { - assert_eq!( - parse_github_repo_path("https://gitlab.com/owner/repo.git"), - None - ); + assert_repo_path("https://gitlab.com/owner/repo.git", None); + assert_repo_path("owner/repo", None); } } diff --git a/src-tauri/src/github/api.rs b/src-tauri/src/github/api.rs deleted file mode 100644 index 9e14b5db..00000000 --- a/src-tauri/src/github/api.rs +++ /dev/null @@ -1,326 +0,0 @@ -use super::{GitHubUser, GithubRepo}; - -/// Lists the authenticated user's GitHub repositories. -pub async fn github_list_repos(token: &str) -> Result, String> { - github_list_repos_with_base(token, "https://api.github.com").await -} - -async fn github_list_repos_with_base( - token: &str, - api_base: &str, -) -> Result, String> { - let client = reqwest::Client::new(); - let mut all_repos: Vec = Vec::new(); - let mut page = 1u32; - - loop { - let url = format!( - "{}/user/repos?per_page=100&sort=updated&page={}", - api_base, page - ); - let response = client - .get(&url) - .header("Authorization", format!("Bearer {}", token)) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "Tolaria-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) -} - -/// Creates a new GitHub repository for the authenticated user. -pub async fn github_create_repo( - token: &str, - name: &str, - private: bool, -) -> Result { - github_create_repo_with_base(token, name, private, "https://api.github.com").await -} - -async fn github_create_repo_with_base( - token: &str, - name: &str, - private: bool, - api_base: &str, -) -> Result { - let client = reqwest::Client::new(); - let body = serde_json::json!({ - "name": name, - "private": private, - "auto_init": true, - "description": "Tolaria vault" - }); - - let response = client - .post(format!("{}/user/repos", api_base)) - .header("Authorization", format!("Bearer {}", token)) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "Tolaria-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)); - } - - response - .json::() - .await - .map_err(|e| format!("Failed to parse GitHub response: {}", e)) -} - -/// Gets the authenticated GitHub user's profile. -pub async fn github_get_user(token: &str) -> Result { - github_get_user_with_base(token, "https://api.github.com").await -} - -async fn github_get_user_with_base(token: &str, api_base: &str) -> Result { - let client = reqwest::Client::new(); - let response = client - .get(format!("{}/user", api_base)) - .header("Authorization", format!("Bearer {}", token)) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "Tolaria-App") - .header("X-GitHub-Api-Version", "2022-11-28") - .send() - .await - .map_err(|e| format!("GitHub user 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)); - } - - response - .json::() - .await - .map_err(|e| format!("Failed to parse user response: {}", e)) -} - -#[cfg(test)] -mod tests { - use super::*; - - async fn mock_json( - method: &str, - path: &str, - status: usize, - body: &str, - ) -> (mockito::ServerGuard, mockito::Mock) { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock(method, path) - .with_status(status) - .with_header("content-type", "application/json") - .with_body(body) - .create_async() - .await; - (server, mock) - } - - async fn mock_list_repos(status: usize, body: &str) -> Result, String> { - let (server, mock) = mock_json( - "GET", - "/user/repos?per_page=100&sort=updated&page=1", - status, - body, - ) - .await; - let result = github_list_repos_with_base("token", &server.url()).await; - mock.assert_async().await; - result - } - - async fn mock_create_repo(status: usize, body: &str) -> Result { - let (server, mock) = mock_json("POST", "/user/repos", status, body).await; - let result = github_create_repo_with_base("token", "repo", false, &server.url()).await; - mock.assert_async().await; - result - } - - async fn mock_user(status: usize, body: &str) -> Result { - let (server, mock) = mock_json("GET", "/user", status, body).await; - let result = github_get_user_with_base("token", &server.url()).await; - mock.assert_async().await; - result - } - - #[tokio::test] - async fn test_github_list_repos_success() { - let repos = mock_list_repos( - 200, - r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#, - ) - .await - .unwrap(); - - assert_eq!(repos.len(), 1); - assert_eq!(repos[0].name, "my-repo"); - } - - #[tokio::test] - async fn test_github_list_repos_empty() { - let repos = mock_list_repos(200, "[]").await.unwrap(); - assert!(repos.is_empty()); - } - - #[tokio::test] - async fn test_github_list_repos_auth_error() { - let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#) - .await - .unwrap_err(); - assert!(err.contains("GitHub API error")); - } - - #[tokio::test] - async fn test_github_list_repos_paginated() { - let mut server = mockito::Server::new_async().await; - - let repos_page1: Vec = (0..100) - .map(|i| { - serde_json::json!({ - "name": format!("repo-{}", i), - "full_name": format!("user/repo-{}", i), - "description": null, - "private": false, - "clone_url": format!("https://github.com/user/repo-{}.git", i), - "html_url": format!("https://github.com/user/repo-{}", i), - "updated_at": null - }) - }) - .collect(); - - let mock1 = server - .mock("GET", "/user/repos?per_page=100&sort=updated&page=1") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(serde_json::to_string(&repos_page1).unwrap()) - .create_async() - .await; - let mock2 = server - .mock("GET", "/user/repos?per_page=100&sort=updated&page=2") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#, - ) - .create_async() - .await; - - let repos = github_list_repos_with_base("token", &server.url()) - .await - .unwrap(); - mock1.assert_async().await; - mock2.assert_async().await; - - assert_eq!(repos.len(), 101); - assert_eq!(repos[100].name, "extra-repo"); - } - - #[tokio::test] - async fn test_github_create_repo_success() { - let repo = mock_create_repo( - 201, - r#"{"name":"new-repo","full_name":"user/new-repo","description":"Tolaria vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#, - ) - .await - .unwrap(); - - assert_eq!(repo.name, "new-repo"); - assert!(repo.private); - } - - #[tokio::test] - async fn test_github_create_repo_name_exists() { - let err = mock_create_repo( - 422, - r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#, - ) - .await - .unwrap_err(); - assert!(err.contains("Repository name already exists")); - } - - #[tokio::test] - async fn test_github_create_repo_server_error() { - let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#) - .await - .unwrap_err(); - assert!(err.contains("GitHub API error 500")); - } - - #[tokio::test] - async fn test_github_get_user_success() { - let user = mock_user( - 200, - r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#, - ) - .await - .unwrap(); - - assert_eq!( - user, - GitHubUser { - login: "lucaong".to_string(), - name: Some("Luca Ongaro".to_string()), - avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(), - } - ); - } - - #[tokio::test] - async fn test_github_get_user_unauthorized() { - let err = mock_user(401, r#"{"message":"Bad credentials"}"#) - .await - .unwrap_err(); - assert!(err.contains("GitHub API error 401")); - } - - #[tokio::test] - async fn test_github_get_user_null_name() { - let user = mock_user( - 200, - r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#, - ) - .await - .unwrap(); - - assert_eq!(user.login, "bot-account"); - assert!(user.name.is_none()); - } -} diff --git a/src-tauri/src/github/auth.rs b/src-tauri/src/github/auth.rs deleted file mode 100644 index e334dc92..00000000 --- a/src-tauri/src/github/auth.rs +++ /dev/null @@ -1,242 +0,0 @@ -use serde::Deserialize; - -use super::{DeviceFlowPollResult, DeviceFlowStart, GITHUB_CLIENT_ID}; - -/// Starts the GitHub OAuth device flow. Returns device code info for user authorization. -pub async fn github_device_flow_start() -> Result { - github_device_flow_start_with_base("https://github.com").await -} - -async fn github_device_flow_start_with_base(base_url: &str) -> Result { - let client = reqwest::Client::new(); - let response = client - .post(format!("{}/login/device/code", base_url)) - .header("Accept", "application/json") - .header("User-Agent", "Tolaria-App") - .form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")]) - .send() - .await - .map_err(|e| format!("Device flow request failed: {}", e))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if status.as_u16() == 404 { - return Err( - "GitHub device flow not available. Ensure a GitHub App is registered with \ - 'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)." - .to_string(), - ); - } - return Err(format!("Device flow start failed ({}): {}", status, body)); - } - - response - .json::() - .await - .map_err(|e| format!("Failed to parse device flow response: {}", e)) -} - -/// Polls GitHub for the device flow authorization result. -pub async fn github_device_flow_poll(device_code: &str) -> Result { - github_device_flow_poll_with_base(device_code, "https://github.com").await -} - -async fn github_device_flow_poll_with_base( - device_code: &str, - base_url: &str, -) -> Result { - let client = reqwest::Client::new(); - let response = client - .post(format!("{}/login/oauth/access_token", base_url)) - .header("Accept", "application/json") - .header("User-Agent", "Tolaria-App") - .form(&[ - ("client_id", GITHUB_CLIENT_ID), - ("device_code", device_code), - ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), - ]) - .send() - .await - .map_err(|e| format!("Device flow poll failed: {}", e))?; - - if !response.status().is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(format!("Device flow poll HTTP error: {}", body)); - } - - #[derive(Deserialize)] - struct RawResponse { - access_token: Option, - error: Option, - } - - let raw: RawResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse poll response: {}", e))?; - - if let Some(token) = raw.access_token { - Ok(DeviceFlowPollResult { - status: "complete".to_string(), - access_token: Some(token), - error: None, - }) - } else { - let error = raw.error.unwrap_or_else(|| "unknown".to_string()); - let status = match error.as_str() { - "authorization_pending" | "slow_down" => "pending", - "expired_token" => "expired", - _ => "error", - }; - Ok(DeviceFlowPollResult { - status: status.to_string(), - access_token: None, - error: Some(error), - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - async fn mock_json( - method: &str, - path: &str, - status: usize, - body: &str, - ) -> (mockito::ServerGuard, mockito::Mock) { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock(method, path) - .with_status(status) - .with_header("content-type", "application/json") - .with_body(body) - .create_async() - .await; - (server, mock) - } - - async fn mock_device_start(status: usize, body: &str) -> Result { - let (server, mock) = mock_json("POST", "/login/device/code", status, body).await; - let result = github_device_flow_start_with_base(&server.url()).await; - mock.assert_async().await; - result - } - - async fn mock_poll(body: &str) -> DeviceFlowPollResult { - let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await; - let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url()) - .await - .unwrap(); - mock.assert_async().await; - result - } - - #[tokio::test] - async fn test_github_device_flow_start_success() { - let start = mock_device_start( - 200, - r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#, - ) - .await - .unwrap(); - - assert_eq!( - start, - DeviceFlowStart { - device_code: "dev_abc".to_string(), - user_code: "ABCD-1234".to_string(), - verification_uri: "https://github.com/login/device".to_string(), - expires_in: 900, - interval: 5, - } - ); - } - - #[tokio::test] - async fn test_github_device_flow_start_error() { - let err = mock_device_start(400, "bad request").await.unwrap_err(); - assert!(err.contains("Device flow start failed")); - } - - #[tokio::test] - async fn test_github_device_flow_start_404_gives_clear_message() { - let err = mock_device_start(404, r#"{"error":"Not Found"}"#) - .await - .unwrap_err(); - assert!(err.contains("device flow not available")); - assert!(err.contains("Device authorization flow")); - } - - #[tokio::test] - async fn test_github_device_flow_poll_complete() { - let poll = - mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#) - .await; - assert_eq!( - poll, - DeviceFlowPollResult { - status: "complete".to_string(), - access_token: Some("gho_secret123".to_string()), - error: None, - } - ); - } - - #[tokio::test] - async fn test_github_device_flow_poll_pending() { - let poll = mock_poll( - r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#, - ) - .await; - assert_eq!(poll.status, "pending"); - assert_eq!(poll.error, Some("authorization_pending".to_string())); - } - - #[tokio::test] - async fn test_github_device_flow_poll_slow_down() { - let poll = mock_poll(r#"{"error":"slow_down"}"#).await; - assert_eq!(poll.status, "pending"); - assert_eq!(poll.error, Some("slow_down".to_string())); - } - - #[tokio::test] - async fn test_github_device_flow_poll_expired() { - let poll = mock_poll(r#"{"error":"expired_token"}"#).await; - assert_eq!(poll.status, "expired"); - assert_eq!(poll.error, Some("expired_token".to_string())); - } - - #[tokio::test] - async fn test_github_device_flow_poll_other_error() { - let poll = mock_poll(r#"{"error":"access_denied"}"#).await; - assert_eq!(poll.status, "error"); - assert_eq!(poll.error, Some("access_denied".to_string())); - } - - #[tokio::test] - async fn test_github_device_flow_poll_http_error() { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock("POST", "/login/oauth/access_token") - .with_status(503) - .with_body("Service Unavailable") - .create_async() - .await; - - let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url()) - .await - .unwrap_err(); - mock.assert_async().await; - assert!(err.contains("Device flow poll HTTP error")); - } - - #[tokio::test] - async fn test_github_device_flow_poll_unknown_error() { - let poll = mock_poll(r#"{}"#).await; - assert_eq!(poll.status, "error"); - assert_eq!(poll.error, Some("unknown".to_string())); - } -} diff --git a/src-tauri/src/github/clone.rs b/src-tauri/src/github/clone.rs deleted file mode 100644 index cfc7dd18..00000000 --- a/src-tauri/src/github/clone.rs +++ /dev/null @@ -1,324 +0,0 @@ -use std::path::Path; -use std::process::Command; - -/// 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); - prepare_clone_destination(dest, local_path)?; - - // Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/... - let auth_url = inject_token_into_url(url, token)?; - - if let Err(err) = run_clone(&auth_url, local_path) { - cleanup_failed_clone(dest); - return Err(err); - } - - // Configure the remote to use token auth for future pushes - if let Err(err) = configure_remote_auth(local_path, url, token) { - cleanup_failed_clone(dest); - return Err(err); - } - - // Ensure sensible .gitignore defaults (especially .DS_Store on macOS) - crate::git::ensure_gitignore(local_path)?; - - Ok(format!("Cloned to {}", local_path)) -} - -/// Clones a public repo to a local path without modifying the remote URL. -pub fn clone_public_repo(url: &str, local_path: &str) -> Result { - let dest = Path::new(local_path); - prepare_clone_destination(dest, local_path)?; - - if let Err(err) = run_clone(url, local_path) { - cleanup_failed_clone(dest); - return Err(err); - } - - 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 - )) - } -} - -fn prepare_clone_destination(dest: &Path, local_path: &str) -> Result<(), String> { - if dest.exists() { - if !dest.is_dir() { - return Err(format!( - "Destination '{}' already exists and is not a directory", - local_path - )); - } - let has_entries = dest - .read_dir() - .map_err(|e| format!("Failed to inspect destination '{}': {}", local_path, e))? - .next() - .is_some(); - if has_entries { - return Err(format!( - "Destination '{}' already exists and is not empty", - local_path - )); - } - return Ok(()); - } - - if let Some(parent) = dest.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).map_err(|e| { - format!( - "Failed to create parent directory for '{}': {}", - local_path, e - ) - })?; - } - } - - Ok(()) -} - -fn run_clone(url: &str, local_path: &str) -> Result<(), String> { - let output = Command::new("git") - .args(["clone", "--progress", url, local_path]) - .output() - .map_err(|e| format!("Failed to run git clone: {}", e))?; - - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("git clone failed: {}", stderr.trim())) -} - -fn cleanup_failed_clone(dest: &Path) { - if dest.exists() && dest.is_dir() { - let _ = std::fs::remove_dir_all(dest); - } -} - -/// 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", "tolaria@app.local"]) - .current_dir(vault) - .output(); - let _ = Command::new("git") - .args(["config", "user.name", "Tolaria App"]) - .current_dir(vault) - .output(); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::process::Command as StdCommand; - - fn clone_err_contains(url: &str, expected: &str) { - let dir = tempfile::TempDir::new().unwrap(); - let dest = dir.path().join("dest"); - let result = clone_repo(url, "token", dest.to_str().unwrap()); - assert!(result.unwrap_err().contains(expected)); - } - - #[test] - fn test_inject_token_basic_github_url() { - let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123"); - assert_eq!( - result.unwrap(), - "https://oauth2:gho_abc123@github.com/user/repo.git" - ); - } - - #[test] - fn test_inject_token_generic_https_url() { - let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc"); - assert_eq!( - result.unwrap(), - "https://oauth2:glpat-abc@gitlab.com/user/repo.git" - ); - } - - #[test] - fn test_inject_token_ssh_url_rejected() { - let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err(); - assert!(err.contains("Unsupported URL format")); - } - - #[test] - fn test_inject_token_http_url_rejected() { - assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err()); - } - - #[test] - fn test_inject_token_github_without_dot_git() { - let result = inject_token_into_url("https://github.com/user/repo", "tok"); - assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo"); - } - - #[test] - fn test_clone_repo_nonempty_dest() { - let dir = tempfile::TempDir::new().unwrap(); - std::fs::write(dir.path().join("existing.txt"), "data").unwrap(); - - let result = clone_repo( - "https://github.com/test/repo.git", - "token", - dir.path().to_str().unwrap(), - ); - assert!(result.unwrap_err().contains("not empty")); - } - - #[test] - fn test_clone_repo_ssh_url_rejected() { - clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format"); - } - - #[test] - fn test_clone_repo_empty_dest_allowed() { - let dir = tempfile::TempDir::new().unwrap(); - let dest = dir.path().join("empty-dir"); - std::fs::create_dir(&dest).unwrap(); - - let result = clone_repo( - "https://github.com/nonexistent/repo.git", - "token", - dest.to_str().unwrap(), - ); - // 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(); - - 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(); - - configure_remote_auth( - path.to_str().unwrap(), - "https://github.com/user/repo.git", - "gho_test123", - ) - .unwrap(); - - 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"); - } - - fn init_local_repo(path: &Path) { - std::fs::create_dir_all(path).unwrap(); - std::fs::write(path.join("welcome.md"), "# Welcome\n").unwrap(); - - StdCommand::new("git") - .args(["init"]) - .current_dir(path) - .output() - .unwrap(); - StdCommand::new("git") - .args(["config", "user.email", "tolaria@app.local"]) - .current_dir(path) - .output() - .unwrap(); - StdCommand::new("git") - .args(["config", "user.name", "Tolaria App"]) - .current_dir(path) - .output() - .unwrap(); - StdCommand::new("git") - .args(["add", "."]) - .current_dir(path) - .output() - .unwrap(); - StdCommand::new("git") - .args(["commit", "-m", "Initial vault"]) - .current_dir(path) - .output() - .unwrap(); - } - - #[test] - fn test_clone_public_repo_clones_local_repo() { - let dir = tempfile::TempDir::new().unwrap(); - let source = dir.path().join("source"); - let dest = dir.path().join("dest"); - init_local_repo(&source); - - let result = clone_public_repo(source.to_str().unwrap(), dest.to_str().unwrap()); - - assert_eq!( - result.unwrap(), - format!("Cloned to {}", dest.to_string_lossy()) - ); - assert!(dest.join("welcome.md").exists()); - - let status = StdCommand::new("git") - .args(["status", "--porcelain"]) - .current_dir(&dest) - .output() - .unwrap(); - assert!(String::from_utf8_lossy(&status.stdout).trim().is_empty()); - } - - #[test] - fn test_clone_public_repo_cleans_failed_clone_destination() { - let dir = tempfile::TempDir::new().unwrap(); - let dest = dir.path().join("dest"); - let missing = dir.path().join("missing-repo"); - - let result = clone_public_repo(missing.to_str().unwrap(), dest.to_str().unwrap()); - - assert!(result.unwrap_err().contains("git clone failed")); - assert!(!dest.exists()); - } -} diff --git a/src-tauri/src/github/mod.rs b/src-tauri/src/github/mod.rs deleted file mode 100644 index 56ac1aac..00000000 --- a/src-tauri/src/github/mod.rs +++ /dev/null @@ -1,139 +0,0 @@ -mod api; -mod auth; -mod clone; - -use serde::{Deserialize, Serialize}; - -pub use api::{github_create_repo, github_get_user, github_list_repos}; -pub use auth::{github_device_flow_poll, github_device_flow_start}; -pub use clone::{clone_public_repo, clone_repo}; - -/// GitHub App client ID for OAuth device flow. -/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App. -/// Enable "Device authorization flow" under Optional features. Webhook can be disabled. -const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L"; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -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, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct DeviceFlowStart { - pub device_code: String, - pub user_code: String, - pub verification_uri: String, - pub expires_in: u64, - pub interval: u64, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct DeviceFlowPollResult { - pub status: String, - pub access_token: Option, - pub error: Option, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct GitHubUser { - pub login: String, - pub name: Option, - pub avatar_url: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_github_repo_serialization_roundtrip() { - 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(); - assert_eq!(serde_json::from_str::(&json).unwrap(), repo); - } - - #[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.private); - assert!(repo.description.is_none()); - assert!(repo.updated_at.is_none()); - } - - #[test] - fn test_device_flow_start_serialization_roundtrip() { - let start = DeviceFlowStart { - device_code: "dc_123".to_string(), - user_code: "ABCD-1234".to_string(), - verification_uri: "https://github.com/login/device".to_string(), - expires_in: 900, - interval: 5, - }; - let json = serde_json::to_string(&start).unwrap(); - assert_eq!( - serde_json::from_str::(&json).unwrap(), - start - ); - } - - #[test] - fn test_device_flow_poll_result_roundtrip() { - let complete = DeviceFlowPollResult { - status: "complete".to_string(), - access_token: Some("gho_abc123".to_string()), - error: None, - }; - let json = serde_json::to_string(&complete).unwrap(); - assert_eq!( - serde_json::from_str::(&json).unwrap(), - complete - ); - - let pending = DeviceFlowPollResult { - status: "pending".to_string(), - access_token: None, - error: Some("authorization_pending".to_string()), - }; - let json = serde_json::to_string(&pending).unwrap(); - assert_eq!( - serde_json::from_str::(&json).unwrap(), - pending - ); - } - - #[test] - fn test_github_user_serialization_roundtrip() { - let user = GitHubUser { - login: "lucaong".to_string(), - name: Some("Luca Ongaro".to_string()), - avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(), - }; - let json = serde_json::to_string(&user).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), user); - } - - #[test] - fn test_github_user_deserialization_null_name() { - let user: GitHubUser = - serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#) - .unwrap(); - assert_eq!(user.login, "bot"); - assert!(user.name.is_none()); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5cbd408a..dc448dff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,6 @@ pub mod claude_cli; mod commands; pub mod frontmatter; pub mod git; -pub mod github; pub mod mcp; #[cfg(desktop)] pub mod menu; @@ -193,12 +192,7 @@ fn with_invoke_handler(builder: tauri::Builder) -> tauri::Builder, - pub github_username: Option, pub auto_pull_interval_minutes: Option, pub telemetry_consent: Option, pub crash_reporting_enabled: Option, @@ -60,16 +58,8 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> { .map_err(|e| format!("Failed to create config directory: {}", e))?; } - // Trim whitespace and convert empty strings to None + // Trim whitespace and convert empty strings to None. let cleaned = Settings { - github_token: settings - .github_token - .map(|k| k.trim().to_string()) - .filter(|k| !k.is_empty()), - github_username: settings - .github_username - .map(|k| k.trim().to_string()) - .filter(|k| !k.is_empty()), auto_pull_interval_minutes: settings.auto_pull_interval_minutes, telemetry_consent: settings.telemetry_consent, crash_reporting_enabled: settings.crash_reporting_enabled, @@ -129,6 +119,33 @@ pub fn set_last_vault(vault_path: &str) -> Result<(), String> { mod tests { use super::*; + fn settings_snapshot( + settings: &Settings, + ) -> ( + Option, + Option, + Option, + Option, + Option<&str>, + Option<&str>, + ) { + ( + settings.auto_pull_interval_minutes, + settings.telemetry_consent, + settings.crash_reporting_enabled, + settings.analytics_enabled, + settings.anonymous_id.as_deref(), + settings.release_channel.as_deref(), + ) + } + + fn assert_empty_settings(settings: &Settings) { + assert_eq!( + settings_snapshot(settings), + (None, None, None, None, None, None) + ); + } + /// Helper: save settings to a temp file and reload them. fn save_and_reload(settings: Settings) -> Settings { let dir = tempfile::TempDir::new().unwrap(); @@ -137,37 +154,38 @@ mod tests { get_settings_at(&path).unwrap() } + fn create_last_vault_path(path_parts: &[&str]) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::TempDir::new().unwrap(); + let path = path_parts + .iter() + .fold(dir.path().to_path_buf(), |acc, part| acc.join(part)); + (dir, path) + } + + fn write_and_assert_last_vault(path: &PathBuf, value: &str) { + set_last_vault_at(path, value).unwrap(); + assert_eq!(get_last_vault_at(path).as_deref(), Some(value)); + } + #[test] fn test_default_settings_all_none() { - let s = Settings::default(); - assert!(s.github_token.is_none()); - assert!(s.github_username.is_none()); - assert!(s.auto_pull_interval_minutes.is_none()); - assert!(s.telemetry_consent.is_none()); - assert!(s.crash_reporting_enabled.is_none()); - assert!(s.analytics_enabled.is_none()); - assert!(s.anonymous_id.is_none()); + assert_empty_settings(&Settings::default()); } #[test] fn test_settings_json_roundtrip() { let settings = Settings { - github_token: Some("gho_xyz789".to_string()), - github_username: Some("lucaong".to_string()), + auto_pull_interval_minutes: Some(10), telemetry_consent: Some(true), crash_reporting_enabled: Some(true), analytics_enabled: Some(false), anonymous_id: Some("abc-123-uuid".to_string()), + release_channel: Some("beta".to_string()), ..Default::default() }; let json = serde_json::to_string(&settings).unwrap(); let parsed: Settings = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.github_token, settings.github_token); - assert_eq!(parsed.github_username, settings.github_username); - assert_eq!(parsed.telemetry_consent, Some(true)); - assert_eq!(parsed.crash_reporting_enabled, Some(true)); - assert_eq!(parsed.analytics_enabled, Some(false)); - assert_eq!(parsed.anonymous_id.as_deref(), Some("abc-123-uuid")); + assert_eq!(settings_snapshot(&parsed), settings_snapshot(&settings)); } #[test] @@ -175,40 +193,38 @@ mod tests { let dir = tempfile::TempDir::new().unwrap(); let path = dir.path().join("nonexistent.json"); let result = get_settings_at(&path).unwrap(); - assert!(result.github_token.is_none()); + assert!(result.auto_pull_interval_minutes.is_none()); } #[test] fn test_save_and_load_preserves_values() { let loaded = save_and_reload(Settings { - github_token: Some("gho_token123".to_string()), - github_username: Some("lucaong".to_string()), auto_pull_interval_minutes: Some(10), + release_channel: Some("alpha".to_string()), ..Default::default() }); - assert_eq!(loaded.github_token.as_deref(), Some("gho_token123")); - assert_eq!(loaded.github_username.as_deref(), Some("lucaong")); assert_eq!(loaded.auto_pull_interval_minutes, Some(10)); + assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); } #[test] fn test_save_trims_whitespace() { let loaded = save_and_reload(Settings { - github_token: Some(" gho_abc ".to_string()), - github_username: Some(" lucaong ".to_string()), + anonymous_id: Some(" test-uuid ".to_string()), + release_channel: Some(" beta ".to_string()), ..Default::default() }); - assert_eq!(loaded.github_token.as_deref(), Some("gho_abc")); - assert_eq!(loaded.github_username.as_deref(), Some("lucaong")); + assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid")); + assert_eq!(loaded.release_channel.as_deref(), Some("beta")); } #[test] fn test_save_filters_empty_and_whitespace_only() { let loaded = save_and_reload(Settings { - github_username: Some("".to_string()), + release_channel: Some("".to_string()), ..Default::default() }); - assert!(loaded.github_username.is_none()); + assert!(loaded.release_channel.is_none()); } #[test] @@ -219,15 +235,15 @@ mod tests { save_settings_at( &path, Settings { - github_token: Some("gho_test".to_string()), + anonymous_id: Some("test-uuid".to_string()), ..Default::default() }, ) .unwrap(); assert!(path.exists()); assert_eq!( - get_settings_at(&path).unwrap().github_token.as_deref(), - Some("gho_test") + get_settings_at(&path).unwrap().anonymous_id.as_deref(), + Some("test-uuid") ); } @@ -250,24 +266,31 @@ mod tests { anonymous_id: Some("test-uuid-v4".to_string()), ..Default::default() }); - assert_eq!(loaded.telemetry_consent, Some(true)); - assert_eq!(loaded.crash_reporting_enabled, Some(true)); - assert_eq!(loaded.analytics_enabled, Some(false)); - assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid-v4")); + assert_eq!( + settings_snapshot(&loaded), + ( + None, + Some(true), + Some(true), + Some(false), + Some("test-uuid-v4"), + None + ) + ); } #[test] fn test_old_settings_json_missing_telemetry_fields() { let dir = tempfile::TempDir::new().unwrap(); let path = dir.path().join("settings.json"); - // Simulate old settings.json without telemetry fields - fs::write(&path, r#"{"github_token":"gho_test"}"#).unwrap(); + // Simulate an old settings.json that still contains removed GitHub auth fields. + fs::write( + &path, + r#"{"github_token":"gho_test","github_username":"lucaong"}"#, + ) + .unwrap(); let loaded = get_settings_at(&path).unwrap(); - assert_eq!(loaded.github_token.as_deref(), Some("gho_test")); - assert!(loaded.telemetry_consent.is_none()); - assert!(loaded.crash_reporting_enabled.is_none()); - assert!(loaded.analytics_enabled.is_none()); - assert!(loaded.anonymous_id.is_none()); + assert_empty_settings(&loaded); } #[test] @@ -299,24 +322,14 @@ mod tests { #[test] fn test_set_and_get_last_vault_roundtrip() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join("last-vault.txt"); - set_last_vault_at(&path, "/Users/test/MyVault").unwrap(); - assert_eq!( - get_last_vault_at(&path).as_deref(), - Some("/Users/test/MyVault") - ); + let (_dir, path) = create_last_vault_path(&["last-vault.txt"]); + write_and_assert_last_vault(&path, "/Users/test/MyVault"); } #[test] fn test_set_last_vault_trims_whitespace() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join("last-vault.txt"); - set_last_vault_at(&path, " /Users/test/Vault ").unwrap(); - assert_eq!( - get_last_vault_at(&path).as_deref(), - Some("/Users/test/Vault") - ); + let (_dir, path) = create_last_vault_path(&["last-vault.txt"]); + write_and_assert_last_vault(&path, "/Users/test/Vault"); } #[test] @@ -329,25 +342,15 @@ mod tests { #[test] fn test_set_last_vault_creates_parent_directories() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join("nested").join("dir").join("last-vault.txt"); - set_last_vault_at(&path, "/Users/test/Vault").unwrap(); + let (_dir, path) = create_last_vault_path(&["nested", "dir", "last-vault.txt"]); + write_and_assert_last_vault(&path, "/Users/test/Vault"); assert!(path.exists()); - assert_eq!( - get_last_vault_at(&path).as_deref(), - Some("/Users/test/Vault") - ); } #[test] fn test_set_last_vault_overwrites_previous() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join("last-vault.txt"); - set_last_vault_at(&path, "/Users/test/OldVault").unwrap(); - set_last_vault_at(&path, "/Users/test/NewVault").unwrap(); - assert_eq!( - get_last_vault_at(&path).as_deref(), - Some("/Users/test/NewVault") - ); + let (_dir, path) = create_last_vault_path(&["last-vault.txt"]); + write_and_assert_last_vault(&path, "/Users/test/OldVault"); + write_and_assert_last_vault(&path, "/Users/test/NewVault"); } } diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index 5bb89c47..1e29205c 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -115,7 +115,7 @@ fn create_getting_started_vault_from_repo( return Err("Target path is required".to_string()); } - crate::github::clone_public_repo(repo_url, target_path)?; + crate::git::clone_repo(repo_url, target_path)?; canonical_vault_path(target_path) } diff --git a/src/App.test.tsx b/src/App.test.tsx index 89068134..2052c785 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -69,7 +69,7 @@ const mockCommandResults: Record = { get_modified_files: [], get_note_content: mockAllContent['/vault/project/test.md'] || '', get_file_history: [], - get_settings: { github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null }, + get_settings: { auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null }, git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }, save_settings: null, check_vault_exists: true, diff --git a/src/App.tsx b/src/App.tsx index 328052cb..cc446ae3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ import { CommitDialog } from './components/CommitDialog' import { PulseView } from './components/PulseView' import { StatusBar } from './components/StatusBar' import { SettingsPanel } from './components/SettingsPanel' -import { GitHubVaultModal } from './components/GitHubVaultModal' +import { CloneVaultModal } from './components/CloneVaultModal' import { WelcomeScreen } from './components/WelcomeScreen' import { TelemetryConsentDialog } from './components/TelemetryConsentDialog' import { FeedbackDialog } from './components/FeedbackDialog' @@ -737,7 +737,7 @@ function App() { - handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} /> setToastMessage(null)} /> - { dialogs.closeGitHubVault(); dialogs.openSettings() }} - onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })} - /> + {deleteActions.confirmDelete && ( ({ + isTauri: () => false, + mockInvoke: vi.fn(), +})) + +import { mockInvoke } from '../mock-tauri' + +const mockInvokeFn = vi.mocked(mockInvoke) + +describe('CloneVaultModal', () => { + const onClose = vi.fn() + const onVaultCloned = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockInvokeFn.mockResolvedValue('Cloned successfully') + }) + + it('renders nothing when not open', () => { + const { container } = render( + + ) + + expect(container.querySelector('[data-testid="clone-vault-modal"]')).not.toBeInTheDocument() + }) + + it('renders the clone form when open', () => { + render() + + expect(screen.getByText('Clone Git Repo')).toBeInTheDocument() + expect(screen.getByTestId('clone-repo-url')).toBeInTheDocument() + expect(screen.getByTestId('clone-vault-path')).toBeInTheDocument() + }) + + it('suggests a vault path from the repository URL', () => { + render() + + fireEvent.change(screen.getByTestId('clone-repo-url'), { + target: { value: 'https://gitlab.com/user/my-vault.git' }, + }) + + expect(screen.getByTestId('clone-vault-path')).toHaveValue('~/Vaults/my-vault') + }) + + it('calls clone_repo and reports the cloned vault on submit', async () => { + render() + + fireEvent.change(screen.getByTestId('clone-repo-url'), { + target: { value: 'git@github.com:user/my-vault.git' }, + }) + fireEvent.click(screen.getByTestId('clone-vault-submit')) + + await waitFor(() => { + expect(mockInvokeFn).toHaveBeenCalledWith('clone_repo', { + url: 'git@github.com:user/my-vault.git', + localPath: '~/Vaults/my-vault', + }) + }) + + expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault') + expect(onClose).toHaveBeenCalled() + }) + + it('shows the backend error when cloning fails', async () => { + mockInvokeFn.mockRejectedValueOnce(new Error('Permission denied')) + + render() + + fireEvent.change(screen.getByTestId('clone-repo-url'), { + target: { value: 'https://example.com/user/private-vault.git' }, + }) + fireEvent.change(screen.getByTestId('clone-vault-path'), { + target: { value: '~/Vaults/private-vault' }, + }) + fireEvent.click(screen.getByTestId('clone-vault-submit')) + + await waitFor(() => { + expect(screen.getByTestId('clone-vault-error')).toHaveTextContent('Clone failed: Error: Permission denied') + }) + }) +}) diff --git a/src/components/CloneVaultModal.tsx b/src/components/CloneVaultModal.tsx new file mode 100644 index 00000000..574de02a --- /dev/null +++ b/src/components/CloneVaultModal.tsx @@ -0,0 +1,204 @@ +import { useCallback, useRef, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { isTauri, mockInvoke } from '../mock-tauri' + +type CloneStatus = 'idle' | 'cloning' | 'error' + +interface CloneVaultModalProps { + open: boolean + onClose: () => void + onVaultCloned: (path: string, label: string) => void +} + +interface CloneVaultFormState { + repoUrl: string + localPath: string + cloneStatus: CloneStatus + cloneError: string | null + isCloneDisabled: boolean + handleClose: () => void + handleRepoUrlChange: (value: string) => void + handleLocalPathChange: (value: string) => void + handleClone: () => Promise +} + +function tauriCall(cmd: string, args: Record): Promise { + return isTauri() ? invoke(cmd, args) : mockInvoke(cmd, args) +} + +function repoNameFromUrl(url: string): string { + const trimmed = url.trim().replace(/\/+$/g, '') + if (!trimmed) return '' + const segment = trimmed.split(/[/:]/).pop() ?? '' + return segment.replace(/\.git$/i, '') +} + +function suggestedPathFromUrl(url: string): string { + const repoName = repoNameFromUrl(url) + return repoName ? `~/Vaults/${repoName}` : '' +} + +function labelFromPath(path: string): string { + const trimmed = path.trim().replace(/\/+$/g, '') + return trimmed.split('/').pop() || 'Vault' +} + +function shouldSyncSuggestedPath(localPath: string, pathDirty: boolean, previousSuggestedPath: string): boolean { + return !pathDirty || !localPath.trim() || localPath === previousSuggestedPath +} + +function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, label: string) => void): CloneVaultFormState { + const [repoUrl, setRepoUrl] = useState('') + const [localPath, setLocalPath] = useState('') + const [pathDirty, setPathDirty] = useState(false) + const [cloneStatus, setCloneStatus] = useState('idle') + const [cloneError, setCloneError] = useState(null) + const previousSuggestedPathRef = useRef('') + + const resetState = useCallback(() => { + setRepoUrl('') + setLocalPath('') + setPathDirty(false) + setCloneStatus('idle') + setCloneError(null) + previousSuggestedPathRef.current = '' + }, []) + + const handleClose = useCallback(() => { + resetState() + onClose() + }, [onClose, resetState]) + + const handleRepoUrlChange = useCallback((value: string) => { + setRepoUrl(value) + setCloneError(null) + + const nextSuggestedPath = suggestedPathFromUrl(value) + const previousSuggestedPath = previousSuggestedPathRef.current + + if (shouldSyncSuggestedPath(localPath, pathDirty, previousSuggestedPath)) { + setLocalPath(nextSuggestedPath) + } + + previousSuggestedPathRef.current = nextSuggestedPath + }, [localPath, pathDirty]) + + const handleLocalPathChange = useCallback((value: string) => { + setPathDirty(true) + setLocalPath(value) + setCloneError(null) + }, []) + + const handleClone = useCallback(async () => { + const trimmedUrl = repoUrl.trim() + const trimmedPath = localPath.trim() + if (!trimmedUrl || !trimmedPath) return + + setCloneStatus('cloning') + setCloneError(null) + + try { + await tauriCall('clone_repo', { url: trimmedUrl, localPath: trimmedPath }) + onVaultCloned(trimmedPath, labelFromPath(trimmedPath)) + handleClose() + } catch (error) { + setCloneStatus('error') + setCloneError(`Clone failed: ${String(error)}`) + } + }, [handleClose, localPath, onVaultCloned, repoUrl]) + + return { + repoUrl, + localPath, + cloneStatus, + cloneError, + isCloneDisabled: !repoUrl.trim() || !localPath.trim() || cloneStatus === 'cloning', + handleClose, + handleRepoUrlChange, + handleLocalPathChange, + handleClone, + } +} + +export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModalProps) { + const { + repoUrl, + localPath, + cloneStatus, + cloneError, + isCloneDisabled, + handleClose, + handleRepoUrlChange, + handleLocalPathChange, + handleClone, + } = useCloneVaultForm(onClose, onVaultCloned) + const handleOpenChange = useCallback((isOpen: boolean) => { + if (!isOpen) handleClose() + }, [handleClose]) + + return ( + + + + Clone Git Repo + + Clone any remote repository into a local vault folder. Tolaria uses your existing system git + configuration for authentication. + + + +
+
+ + handleRepoUrlChange(event.target.value)} + data-testid="clone-repo-url" + /> +
+ +
+ + handleLocalPathChange(event.target.value)} + data-testid="clone-vault-path" + /> +
+ +

+ SSH keys, the git credential manager, `gh auth`, and other system git auth methods all work. +

+ + {cloneError && ( +

{cloneError}

+ )} +
+ + + + +
+
+ ) +} diff --git a/src/components/GitHubDeviceFlow.tsx b/src/components/GitHubDeviceFlow.tsx deleted file mode 100644 index 9bc13813..00000000 --- a/src/components/GitHubDeviceFlow.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { useState, useRef, useCallback, useEffect } from 'react' -import { GithubLogo, CircleNotch, ArrowClockwise, Copy, Check } from '@phosphor-icons/react' -import { invoke } from '@tauri-apps/api/core' -import { isTauri, mockInvoke } from '../mock-tauri' -import { openExternalUrl } from '../utils/url' -import type { DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types' - -function tauriCall(cmd: string, args: Record = {}): Promise { - return isTauri() ? invoke(cmd, args) : mockInvoke(cmd, args) -} - -type OAuthStatus = 'idle' | 'waiting' | 'error' - -/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */ -function processPollResult( - result: DeviceFlowPollResult, - callbacks: { - onComplete: (token: string) => Promise - onExpired: () => void - onError: (msg: string) => void - }, -): 'done' | 'continue' { - if (result.status === 'complete' && result.access_token) { - callbacks.onComplete(result.access_token) - return 'done' - } - if (result.status === 'expired') { - callbacks.onExpired() - return 'done' - } - if (result.status === 'error') { - callbacks.onError(result.error ?? 'Authorization failed.') - return 'done' - } - return 'continue' -} - -interface GitHubDeviceFlowProps { - onConnected: (token: string, username: string) => void -} - -export function GitHubDeviceFlow({ onConnected }: GitHubDeviceFlowProps) { - const [oauthStatus, setOauthStatus] = useState('idle') - const [userCode, setUserCode] = useState(null) - const [verificationUri, setVerificationUri] = useState(null) - const [errorMessage, setErrorMessage] = useState(null) - const pollingRef = useRef(false) - const deviceCodeRef = useRef(null) - - const stopPolling = useCallback(() => { - pollingRef.current = false - deviceCodeRef.current = null - }, []) - - useEffect(() => { - return () => { pollingRef.current = false } - }, []) - - const handleLogin = useCallback(async () => { - setOauthStatus('waiting') - setErrorMessage(null) - setUserCode(null) - - try { - const flowStart = await tauriCall('github_device_flow_start') - setUserCode(flowStart.user_code) - setVerificationUri(flowStart.verification_uri) - deviceCodeRef.current = flowStart.device_code - openExternalUrl(flowStart.verification_uri).catch(() => {}) - - pollingRef.current = true - const intervalMs = Math.max(flowStart.interval * 1000, 5000) - - const pollLoop = async () => { - while (pollingRef.current && deviceCodeRef.current) { - await new Promise(r => setTimeout(r, intervalMs)) - if (!pollingRef.current) break - - const result = await tauriCall('github_device_flow_poll', { - deviceCode: deviceCodeRef.current, - }) - const outcome = processPollResult(result, { - onComplete: async (token) => { - const user = await tauriCall('github_get_user', { token }) - stopPolling() - setOauthStatus('idle') - setUserCode(null) - onConnected(token, user.login) - }, - onExpired: () => { - stopPolling() - setOauthStatus('error') - setErrorMessage('Authorization expired. Please try again.') - }, - onError: (msg) => { - stopPolling() - setOauthStatus('error') - setErrorMessage(msg) - }, - }) - if (outcome === 'done') return - } - } - - pollLoop().catch(err => { - stopPolling() - setOauthStatus('error') - setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.') - }) - } catch (err) { - setOauthStatus('error') - setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.') - } - }, [onConnected, stopPolling]) - - const resetOAuth = useCallback(() => { - stopPolling() - setOauthStatus('idle') - setUserCode(null) - setVerificationUri(null) - setErrorMessage(null) - }, [stopPolling]) - - if (oauthStatus === 'waiting' && userCode) { - return - } - - return { resetOAuth(); handleLogin() } : undefined} /> -} - -function DeviceCodeView({ userCode, verificationUri, onCancel }: { userCode: string; verificationUri: string | null; onCancel: () => void }) { - const [copied, setCopied] = useState(false) - - const handleCopyCode = useCallback(() => { - navigator.clipboard.writeText(userCode).then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }).catch(() => {}) - }, [userCode]) - - const handleOpenUrl = useCallback(() => { - if (verificationUri) openExternalUrl(verificationUri).catch(() => {}) - }, [verificationUri]) - - return ( -
-
-
Enter this code on GitHub:
-
-
- {userCode} -
- -
- {verificationUri && ( - - )} -
- - Waiting for authorization... -
-
- -
- ) -} - -function LoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) { - return ( -
- - {errorMessage && ( -
- {errorMessage} - {onRetry && ( - - )} -
- )} -
- ) -} diff --git a/src/components/GitHubVaultModal.test.tsx b/src/components/GitHubVaultModal.test.tsx deleted file mode 100644 index a904a70a..00000000 --- a/src/components/GitHubVaultModal.test.tsx +++ /dev/null @@ -1,218 +0,0 @@ -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(), -})) -vi.mock('../utils/url', () => ({ - openExternalUrl: vi.fn().mockResolvedValue(undefined), -})) - -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() - const onGitHubConnected = 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 settings fallback when no token and no onGitHubConnected', () => { - 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 inline device flow when no token and onGitHubConnected is provided', () => { - render( - - ) - expect(screen.getByTestId('github-login')).toBeInTheDocument() - expect(screen.getByText('Login with GitHub')).toBeInTheDocument() - expect(screen.queryByTestId('github-open-settings')).not.toBeInTheDocument() - }) - - it('shows device code after starting inline OAuth flow', async () => { - mockInvokeFn.mockImplementation(async (cmd: string) => { - if (cmd === 'github_device_flow_start') { - return { device_code: 'dc_modal', user_code: 'MODAL-5678', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 } - } - if (cmd === 'github_device_flow_poll') { - return { status: 'pending', access_token: null, error: 'authorization_pending' } - } - return null - }) - - render( - - ) - - fireEvent.click(screen.getByTestId('github-login')) - - await waitFor(() => { - expect(screen.getByTestId('github-user-code')).toHaveTextContent('MODAL-5678') - }) - }) - - 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 deleted file mode 100644 index 187ad836..00000000 --- a/src/components/GitHubVaultModal.tsx +++ /dev/null @@ -1,444 +0,0 @@ -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 { GitHubDeviceFlow } from './GitHubDeviceFlow' -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 - onGitHubConnected?: (token: string, username: string) => 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, onGitHubConnected }: 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. - -
- {onGitHubConnected ? ( - - ) : ( - <> -

- 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 65d5bb7f..52206c36 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -1,25 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' import { SettingsPanel } from './SettingsPanel' import type { Settings } from '../types' -// Mock the tauri/mock-tauri calls used by GitHubSection -const mockInvokeFn = vi.fn() -const mockOpenExternalUrl = vi.fn().mockResolvedValue(undefined) -vi.mock('@tauri-apps/api/core', () => ({ - invoke: (...args: unknown[]) => mockInvokeFn(...args), -})) -vi.mock('../mock-tauri', () => ({ - isTauri: () => false, - mockInvoke: (...args: unknown[]) => mockInvokeFn(...args), -})) -vi.mock('../utils/url', () => ({ - openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args), -})) - const emptySettings: Settings = { - github_token: null, - github_username: null, auto_pull_interval_minutes: null, telemetry_consent: null, crash_reporting_enabled: null, @@ -48,7 +32,7 @@ describe('SettingsPanel', () => { ) expect(screen.getByText('Settings')).toBeInTheDocument() - expect(screen.getByText('GitHub')).toBeInTheDocument() + expect(screen.getByText('Sync')).toBeInTheDocument() }) it('does not show AI Provider Keys section', () => { @@ -68,8 +52,6 @@ describe('SettingsPanel', () => { fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - github_token: null, - github_username: null, auto_pull_interval_minutes: 5, })) expect(onClose).toHaveBeenCalled() @@ -132,8 +114,6 @@ describe('SettingsPanel', () => { fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Enter', metaKey: true }) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - github_token: null, - github_username: null, auto_pull_interval_minutes: 5, })) }) @@ -154,195 +134,12 @@ describe('SettingsPanel', () => { expect(screen.getByText(/to open settings/)).toBeInTheDocument() }) - - describe('GitHub OAuth section', () => { - it('shows Login with GitHub button when not connected', () => { - render( - - ) - expect(screen.getByTestId('github-login')).toBeInTheDocument() - expect(screen.getByText('Login with GitHub')).toBeInTheDocument() - }) - - it('does not show GitHub token input field', () => { - render( - - ) - expect(screen.queryByTestId('settings-key-github-token')).not.toBeInTheDocument() - expect(screen.queryByPlaceholderText('ghp_... or gho_...')).not.toBeInTheDocument() - }) - - it('shows connected state with username when GitHub is connected', () => { - const connectedSettings: Settings = { - ...emptySettings, - github_token: 'gho_test_token', - github_username: 'lucaong', - } - render( - - ) - expect(screen.getByTestId('github-connected')).toBeInTheDocument() - expect(screen.getByText('lucaong')).toBeInTheDocument() - expect(screen.getByText('Connected')).toBeInTheDocument() - expect(screen.getByTestId('github-disconnect')).toBeInTheDocument() - }) - - it('clears GitHub connection on disconnect', () => { - const connectedSettings: Settings = { - ...emptySettings, - github_token: 'gho_test_token', - github_username: 'lucaong', - } - render( - - ) - fireEvent.click(screen.getByTestId('github-disconnect')) - - // onSave should be called with cleared GitHub fields - expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ - github_token: null, - github_username: null, - })) - }) - - it('shows waiting state with user code during OAuth flow', async () => { - mockInvokeFn.mockImplementation(async (cmd: string) => { - if (cmd === 'github_device_flow_start') { - return { - device_code: 'test_device_code', - user_code: 'TEST-1234', - verification_uri: 'https://github.com/login/device', - expires_in: 900, - interval: 5, - } - } - if (cmd === 'github_device_flow_poll') { - return { status: 'pending', access_token: null, error: 'authorization_pending' } - } - return null - }) - - render( - - ) - - fireEvent.click(screen.getByTestId('github-login')) - - await waitFor(() => { - expect(screen.getByTestId('github-waiting')).toBeInTheDocument() - expect(screen.getByTestId('github-user-code')).toHaveTextContent('TEST-1234') - }) - - expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://github.com/login/device') - }) - - it('shows verification URL as clickable link in waiting state', async () => { - mockInvokeFn.mockImplementation(async (cmd: string) => { - if (cmd === 'github_device_flow_start') { - return { - device_code: 'test_device_code', - user_code: 'TEST-1234', - verification_uri: 'https://github.com/login/device', - expires_in: 900, - interval: 5, - } - } - if (cmd === 'github_device_flow_poll') { - return { status: 'pending', access_token: null, error: 'authorization_pending' } - } - return null - }) - - render( - - ) - - fireEvent.click(screen.getByTestId('github-login')) - - await waitFor(() => { - const urlButton = screen.getByTestId('github-open-url') - expect(urlButton).toBeInTheDocument() - expect(urlButton).toHaveTextContent('https://github.com/login/device') - }) - }) - - it('shows retry button when OAuth flow errors', async () => { - mockInvokeFn.mockImplementation(async (cmd: string) => { - if (cmd === 'github_device_flow_start') { - throw 'Network error' - } - return null - }) - - render( - - ) - - fireEvent.click(screen.getByTestId('github-login')) - - await waitFor(() => { - expect(screen.getByTestId('github-error')).toHaveTextContent('Network error') - expect(screen.getByTestId('github-retry')).toBeInTheDocument() - }) - }) - - it('shows GitHub section description about connecting', () => { - render( - - ) - expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument() - }) - - it('displays the actual backend error string when device flow start fails', async () => { - mockInvokeFn.mockImplementation(async (cmd: string) => { - if (cmd === 'github_device_flow_start') { - // Tauri invoke rejects with a plain string, not an Error instance - throw 'GitHub device flow not available. Ensure a GitHub App is registered.' - } - return null - }) - - render( - - ) - - fireEvent.click(screen.getByTestId('github-login')) - - await waitFor(() => { - expect(screen.getByTestId('github-error')).toHaveTextContent( - 'GitHub device flow not available. Ensure a GitHub App is registered.' - ) - }) - }) - - it('prevents double-click by disabling button during login flow', async () => { - let resolveStart: ((v: unknown) => void) | null = null - mockInvokeFn.mockImplementation(async (cmd: string) => { - if (cmd === 'github_device_flow_start') { - return new Promise(r => { resolveStart = r }) - } - return null - }) - - render( - - ) - - const loginBtn = screen.getByTestId('github-login') as HTMLButtonElement - fireEvent.click(loginBtn) - - // Button should be disabled while waiting - await waitFor(() => { - expect(loginBtn.disabled).toBe(true) - }) - - // Clean up - resolveStart?.({ - device_code: 'dc', user_code: 'UC-1234', - verification_uri: 'https://github.com/login/device', - expires_in: 900, interval: 5, - }) - }) + it('does not show the removed GitHub auth section', () => { + render( + + ) + expect(screen.queryByText('GitHub')).not.toBeInTheDocument() + expect(screen.queryByText(/Connect your GitHub account/i)).not.toBeInTheDocument() }) describe('Privacy & Telemetry section', () => { diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index bf16e2f2..60760348 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,6 +1,5 @@ import { useState, useRef, useCallback, useEffect } from 'react' -import { X, GithubLogo, SignOut } from '@phosphor-icons/react' -import { GitHubDeviceFlow } from './GitHubDeviceFlow' +import { X } from '@phosphor-icons/react' import type { Settings } from '../types' import { trackEvent } from '../lib/telemetry' import { Switch } from './ui/switch' @@ -14,52 +13,6 @@ interface SettingsPanelProps { onClose: () => void } - -// --- GitHub OAuth Section --- - -interface GitHubSectionProps { - githubUsername: string | null - githubToken: string | null - onConnected: (token: string, username: string) => void - onDisconnect: () => void -} - -function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect }: GitHubSectionProps) { - const isConnected = !!githubToken && !!githubUsername - - if (isConnected) { - return - } - - return -} - -function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDisconnect: () => void }) { - return ( -
-
- - {username} - Connected -
- -
- ) -} - // --- Settings Panel --- function isSaveShortcut(event: React.KeyboardEvent): boolean { @@ -97,8 +50,6 @@ function SettingsPanelInner({ onSaveExplicitOrganization, onClose, }: SettingsPanelInnerProps) { - const [githubToken, setGithubToken] = useState(settings.github_token) - const [githubUsername, setGithubUsername] = useState(settings.github_username) const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5) const [releaseChannel, setReleaseChannel] = useState(settings.release_channel ?? 'stable') const [crashReporting, setCrashReporting] = useState(settings.crash_reporting_enabled ?? false) @@ -115,16 +66,14 @@ function SettingsPanelInner({ return () => clearTimeout(timer) }, []) - const buildSettings = useCallback((ghOverride?: { token: string | null; username: string | null }): Settings => ({ - github_token: ghOverride ? ghOverride.token : (githubToken ?? null), - github_username: ghOverride ? ghOverride.username : (githubUsername ?? null), + const buildSettings = useCallback((): Settings => ({ auto_pull_interval_minutes: pullInterval, telemetry_consent: (crashReporting || analytics) ? true : (settings.telemetry_consent === null ? null : false), crash_reporting_enabled: crashReporting, analytics_enabled: analytics, anonymous_id: (crashReporting || analytics) ? (settings.anonymous_id ?? crypto.randomUUID()) : settings.anonymous_id, release_channel: releaseChannel === 'stable' ? null : releaseChannel, - }), [githubToken, githubUsername, pullInterval, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id]) + }), [pullInterval, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id]) const handleSave = () => { const prevAnalytics = settings.analytics_enabled ?? false @@ -136,18 +85,6 @@ function SettingsPanelInner({ onClose() } - const handleGitHubConnected = useCallback((token: string, username: string) => { - setGithubToken(token) - setGithubUsername(username) - onSave(buildSettings({ token, username })) - }, [onSave, buildSettings]) - - const handleGitHubDisconnect = useCallback(() => { - setGithubToken(null) - setGithubUsername(null) - onSave(buildSettings({ token: null, username: null })) - }, [onSave, buildSettings]) - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation() @@ -174,8 +111,6 @@ function SettingsPanelInner({ > void }) { } interface SettingsBodyProps { - githubToken: string | null; githubUsername: string | null - onGitHubConnected: (token: string, username: string) => void - onGitHubDisconnect: () => void pullInterval: number; setPullInterval: (v: number) => void releaseChannel: string; setReleaseChannel: (v: string) => void explicitOrganization: boolean; setExplicitOrganization: (v: boolean) => void @@ -221,22 +153,6 @@ interface SettingsBodyProps { function SettingsBody(props: SettingsBodyProps) { return (
-
-
GitHub
-
- Connect your GitHub account to clone and sync vaults. -
-
- - - -
-
Sync
diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 7714c06e..8ffe0fb2 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -187,12 +187,12 @@ describe('StatusBar', () => { vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} - onConnectGitHub={vi.fn()} + onCloneVault={vi.fn()} /> ) fireEvent.click(screen.getByTitle('Switch vault')) expect(screen.getByText('Open local folder')).toBeInTheDocument() - expect(screen.getByText('Connect GitHub repo')).toBeInTheDocument() + expect(screen.getByText('Clone Git repo')).toBeInTheDocument() }) it('shows Changes badge with count when modifiedCount is > 0', () => { diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index d30186ff..adf1f539 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -18,12 +18,11 @@ interface StatusBarProps { onSwitchVault: (path: string) => void onOpenSettings?: () => void onOpenLocalFolder?: () => void - onConnectGitHub?: () => void + onCloneVault?: () => void onClickPending?: () => void onClickPulse?: () => void onCommitPush?: () => void isGitVault?: boolean - hasGitHub?: boolean syncStatus?: SyncStatus lastSyncTime?: number | null conflictCount?: number @@ -52,12 +51,11 @@ export function StatusBar({ onSwitchVault, onOpenSettings, onOpenLocalFolder, - onConnectGitHub, + onCloneVault, onClickPending, onClickPulse, onCommitPush, isGitVault = false, - hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, @@ -107,8 +105,7 @@ export function StatusBar({ vaults={vaults} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} - onConnectGitHub={onConnectGitHub} - hasGitHub={hasGitHub} + onCloneVault={onCloneVault} onClickPending={onClickPending} onClickPulse={onClickPulse} onCommitPush={onCommitPush} diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx index f9a8b26d..c8a41ae9 100644 --- a/src/components/WelcomeScreen.tsx +++ b/src/components/WelcomeScreen.tsx @@ -260,9 +260,9 @@ export function WelcomeScreen({ icon={} iconBg="var(--accent-purple-light, #F3E8FF)" label="Get started with a template" - description={`Download the starter vault from GitHub \u2014 suggested path: ${defaultVaultPath}`} + description={`Download the starter vault template \u2014 suggested path: ${defaultVaultPath}`} loadingLabel="Downloading template…" - loadingDescription="Cloning the Getting Started vault from GitHub" + loadingDescription="Cloning the Getting Started vault template" onClick={onCreateVault} disabled={busy} loading={creatingAction === 'template'} @@ -272,7 +272,7 @@ export function WelcomeScreen({ {creatingAction === 'template' && (

- Downloading the Getting Started vault from GitHub… + Downloading the Getting Started vault template…

)} diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index eb89b71f..053bf7b7 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -24,8 +24,7 @@ interface StatusBarPrimarySectionProps { vaults: VaultOption[] onSwitchVault: (path: string) => void onOpenLocalFolder?: () => void - onConnectGitHub?: () => void - hasGitHub?: boolean + onCloneVault?: () => void onClickPending?: () => void onClickPulse?: () => void onCommitPush?: () => void @@ -61,8 +60,7 @@ export function StatusBarPrimarySection({ vaults, onSwitchVault, onOpenLocalFolder, - onConnectGitHub, - hasGitHub, + onCloneVault, onClickPending, onClickPulse, onCommitPush, @@ -90,8 +88,7 @@ export function StatusBarPrimarySection({ vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} - onConnectGitHub={onConnectGitHub} - hasGitHub={hasGitHub} + onCloneVault={onCloneVault} onRemoveVault={onRemoveVault} /> | diff --git a/src/components/status-bar/VaultMenu.tsx b/src/components/status-bar/VaultMenu.tsx index ecfa8a0e..57fc5365 100644 --- a/src/components/status-bar/VaultMenu.tsx +++ b/src/components/status-bar/VaultMenu.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' -import { AlertTriangle, Check, FolderOpen, Github, X } from 'lucide-react' +import { AlertTriangle, Check, FolderOpen, GitBranch, X } from 'lucide-react' import type { VaultOption } from './types' import { useDismissibleLayer } from './useDismissibleLayer' @@ -9,8 +9,7 @@ interface VaultMenuProps { vaultPath: string onSwitchVault: (path: string) => void onOpenLocalFolder?: () => void - onConnectGitHub?: () => void - hasGitHub?: boolean + onCloneVault?: () => void onRemoveVault?: (path: string) => void } @@ -142,8 +141,7 @@ export function VaultMenu({ vaultPath, onSwitchVault, onOpenLocalFolder, - onConnectGitHub, - hasGitHub, + onCloneVault, onRemoveVault, }: VaultMenuProps) { const [open, setOpen] = useState(false) @@ -166,19 +164,18 @@ export function VaultMenu({ }) } - if (onConnectGitHub) { + if (onCloneVault) { items.push({ - key: 'connect-github', - icon: , - label: 'Connect GitHub repo', - testId: 'vault-menu-connect-github', - accent: !hasGitHub, - onClick: onConnectGitHub, + key: 'clone-git', + icon: , + label: 'Clone Git repo', + testId: 'vault-menu-clone-git', + onClick: onCloneVault, }) } return items - }, [hasGitHub, onConnectGitHub, onOpenLocalFolder]) + }, [onCloneVault, onOpenLocalFolder]) return (
diff --git a/src/hooks/useDialogs.ts b/src/hooks/useDialogs.ts index b62e3495..881a7846 100644 --- a/src/hooks/useDialogs.ts +++ b/src/hooks/useDialogs.ts @@ -7,7 +7,7 @@ export function useDialogs() { const [showCommandPalette, setShowCommandPalette] = useState(false) const [showAIChat, setShowAIChat] = useState(false) const [showSettings, setShowSettings] = useState(false) - const [showGitHubVault, setShowGitHubVault] = useState(false) + const [showCloneVault, setShowCloneVault] = useState(false) const [showSearch, setShowSearch] = useState(false) const [showConflictResolver, setShowConflictResolver] = useState(false) const [showCreateViewDialog, setShowCreateViewDialog] = useState(false) @@ -21,8 +21,8 @@ export function useDialogs() { const closeCommandPalette = useCallback(() => setShowCommandPalette(false), []) const openSettings = useCallback(() => setShowSettings(true), []) const closeSettings = useCallback(() => setShowSettings(false), []) - const openGitHubVault = useCallback(() => setShowGitHubVault(true), []) - const closeGitHubVault = useCallback(() => setShowGitHubVault(false), []) + const openCloneVault = useCallback(() => setShowCloneVault(true), []) + const closeCloneVault = useCallback(() => setShowCloneVault(false), []) const toggleAIChat = useCallback(() => setShowAIChat((c) => !c), []) const openSearch = useCallback(() => setShowSearch(true), []) const closeSearch = useCallback(() => setShowSearch(false), []) @@ -41,7 +41,7 @@ export function useDialogs() { showCommandPalette, openCommandPalette, closeCommandPalette, showAIChat, toggleAIChat, showSettings, openSettings, closeSettings, - showGitHubVault, openGitHubVault, closeGitHubVault, + showCloneVault, openCloneVault, closeCloneVault, showSearch, openSearch, closeSearch, showConflictResolver, openConflictResolver, closeConflictResolver, showCreateViewDialog, openCreateView, closeCreateView, editingView, openEditView, diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index b18bda38..ca90cf7f 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -4,8 +4,6 @@ import type { Settings } from '../types' import { useSettings } from './useSettings' const defaultSettings: Settings = { - github_token: null, - github_username: null, auto_pull_interval_minutes: null, telemetry_consent: null, crash_reporting_enabled: null, @@ -15,9 +13,7 @@ const defaultSettings: Settings = { } const savedSettings: Settings = { - github_token: 'gho_saved_token', - github_username: null, - auto_pull_interval_minutes: null, + auto_pull_interval_minutes: 15, telemetry_consent: null, crash_reporting_enabled: null, analytics_enabled: null, @@ -65,7 +61,7 @@ describe('useSettings', () => { expect(result.current.loaded).toBe(true) }) - expect(result.current.settings.github_token).toBe('gho_saved_token') + expect(result.current.settings.auto_pull_interval_minutes).toBe(15) expect(mockInvokeFn).toHaveBeenCalledWith('get_settings', {}) }) @@ -77,8 +73,6 @@ describe('useSettings', () => { }) const newSettings: Settings = { - github_token: null, - github_username: null, auto_pull_interval_minutes: null, telemetry_consent: null, crash_reporting_enabled: null, diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 9ce6b6a6..861be7ed 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -8,8 +8,6 @@ function tauriCall(command: string, tauriArgs: Record, mockA } const EMPTY_SETTINGS: Settings = { - github_token: null, - github_username: null, auto_pull_interval_minutes: null, telemetry_consent: null, crash_reporting_enabled: null, diff --git a/src/hooks/useTelemetry.test.ts b/src/hooks/useTelemetry.test.ts index d3c5a7b8..1a867bdb 100644 --- a/src/hooks/useTelemetry.test.ts +++ b/src/hooks/useTelemetry.test.ts @@ -18,7 +18,7 @@ vi.mock('../lib/telemetry', () => ({ })) const baseSettings: Settings = { - github_token: null, github_username: null, auto_pull_interval_minutes: null, + auto_pull_interval_minutes: null, telemetry_consent: null, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null, } diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 21253534..1679bda6 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -3,7 +3,7 @@ * Each handler simulates a Tauri backend command. */ -import type { VaultEntry, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, PulseCommit } from '../types' +import type { VaultEntry, ModifiedFile, Settings, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, PulseCommit } from '../types' import { MOCK_CONTENT } from './mock-content' import { MOCK_ENTRIES } from './mock-entries' @@ -91,8 +91,6 @@ let mockHasChanges = true const mockSavedSinceCommit = new Set() let mockSettings: Settings = { - github_token: null, - github_username: null, auto_pull_interval_minutes: 5, telemetry_consent: false, crash_reporting_enabled: null, @@ -108,8 +106,6 @@ let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vaul active_vault: null, } -let mockDeviceFlowPollCount = 0 - function escapeRegex({ text }: { text: string }) { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } @@ -227,8 +223,6 @@ function handleRenameNoteFilename(args: { return { new_path: newPath, updated_files: updatedFiles } } -const trimOrNull = (v: string | null | undefined): string | null => v?.trim() || null - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types export const mockHandlers: Record any> = { list_vault: () => MOCK_ENTRIES, @@ -298,8 +292,6 @@ export const mockHandlers: Record any> = { save_settings: (args: { settings: Settings }) => { const s = args.settings mockSettings = { - github_token: trimOrNull(s.github_token), - github_username: trimOrNull(s.github_username), auto_pull_interval_minutes: s.auto_pull_interval_minutes ?? 5, telemetry_consent: s.telemetry_consent, crash_reporting_enabled: s.crash_reporting_enabled, @@ -313,19 +305,7 @@ export const mockHandlers: Record any> = { save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null }, rename_note: handleRenameNote, rename_note_filename: handleRenameNoteFilename, - 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: 'tolaria', full_name: 'lucaong/tolaria', description: 'Tolaria desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/tolaria.git', html_url: 'https://github.com/lucaong/tolaria', 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: 'Tolaria 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}`, + clone_repo: (args: { url: string; localPath?: string; local_path?: string }) => `Cloned to ${args.localPath ?? args.local_path ?? ''}`, purge_trash: () => [], delete_note: (args: { path: string }) => args.path, batch_delete_notes: (args: { paths: string[] }) => args.paths, @@ -333,18 +313,6 @@ export const mockHandlers: Record any> = { migrate_is_a_to_type: () => 0, batch_archive_notes: (args: { paths: string[] }) => args.paths.length, batch_trash_notes: (args: { paths: string[] }) => args.paths.length, - github_device_flow_start: (): DeviceFlowStart => { - mockDeviceFlowPollCount = 0 - return { device_code: 'mock_device_code_abc123', user_code: 'ABCD-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 } - }, - github_device_flow_poll: (): DeviceFlowPollResult => { - mockDeviceFlowPollCount++ - if (mockDeviceFlowPollCount <= 1) { - return { status: 'pending', access_token: null, error: 'authorization_pending' } - } - return { status: 'complete', access_token: 'gho_mock_oauth_token_xyz', error: null } - }, - github_get_user: (): GitHubUser => ({ login: 'lucaong', name: 'Luca Ongaro', avatar_url: 'https://avatars.githubusercontent.com/u/123456?v=4' }), search_vault: (args: { query: string; mode: string }) => { const q = (args.query ?? '').toLowerCase() if (!q) return { results: [], elapsed_ms: 0, query: q, mode: args.mode } diff --git a/src/types.ts b/src/types.ts index d37d37df..b73abc86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,8 +74,6 @@ export interface ModifiedFile { } export interface Settings { - github_token: string | null - github_username: string | null auto_pull_interval_minutes: number | null telemetry_consent: boolean | null crash_reporting_enabled: boolean | null @@ -105,36 +103,6 @@ export interface GitRemoteStatus { hasRemote: boolean } -export interface DeviceFlowStart { - device_code: string - user_code: string - verification_uri: string - expires_in: number - interval: number -} - -export interface DeviceFlowPollResult { - status: 'pending' | 'complete' | 'expired' | 'error' - access_token: string | null - error: string | null -} - -export interface GitHubUser { - login: string - name: string | null - avatar_url: string -} - -export interface GithubRepo { - name: string - full_name: string - description: string | null - private: boolean - clone_url: string - html_url: string - updated_at: string | null -} - export interface SearchResult { title: string path: string diff --git a/tests/helpers/fixtureVault.ts b/tests/helpers/fixtureVault.ts index 884f543d..c3a2fd74 100644 --- a/tests/helpers/fixtureVault.ts +++ b/tests/helpers/fixtureVault.ts @@ -261,126 +261,130 @@ export async function openFixtureVaultDesktopHarness( return response.json() } + const activeVaultList = { + vaults: [{ label: 'Test Vault', path: resolvedVaultPath }], + active_vault: resolvedVaultPath, + hidden_defaults: [], + } + + const readVaultList = (commandArgs?: Record, reload = false) => { + const resolvedPath = String(commandArgs?.path ?? resolvedVaultPath) + return readJson( + `/api/vault/list?path=${encodeURIComponent(resolvedPath)}&reload=${reload ? '1' : '0'}`, + ) + } + + const renameNoteRequest = (payload: Record) => + readJson('/api/vault/rename', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify(payload), + }) + + const commandHandlers: Record) => Promise | unknown> = { + trigger_menu_command: (commandArgs) => { + const commandId = String(commandArgs?.id ?? '') + const bridge = window.__laputaTest?.dispatchBrowserMenuCommand + if (!bridge) throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand') + bridge(commandId) + return null + }, + load_vault_list: () => activeVaultList, + check_vault_exists: () => true, + is_git_repo: () => true, + get_last_vault_path: () => resolvedVaultPath, + get_default_vault_path: () => resolvedVaultPath, + save_vault_list: () => null, + save_settings: () => null, + register_mcp_tools: () => null, + reinit_telemetry: () => null, + update_menu_state: () => null, + get_settings: () => ({ + auto_pull_interval_minutes: 5, + telemetry_consent: false, + crash_reporting_enabled: null, + analytics_enabled: null, + anonymous_id: null, + release_channel: null, + }), + list_vault: (commandArgs) => readVaultList(commandArgs), + reload_vault: (commandArgs) => readVaultList(commandArgs, true), + list_vault_folders: () => [], + list_views: () => [], + get_modified_files: () => [], + detect_renames: () => [], + reload_vault_entry: (commandArgs) => + readJson(`/api/vault/entry?path=${encodeURIComponent(String(commandArgs?.path ?? ''))}`), + get_note_content: async (commandArgs) => { + const data = await readJson( + `/api/vault/content?path=${encodeURIComponent(String(commandArgs?.path ?? ''))}`, + ) as { content: string } + return data.content + }, + get_all_content: (commandArgs) => + readJson( + `/api/vault/all-content?path=${encodeURIComponent(String(commandArgs?.path ?? resolvedVaultPath))}`, + ), + save_note_content: (commandArgs) => + readJson('/api/vault/save', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ path: commandArgs?.path, content: commandArgs?.content }), + }), + update_frontmatter: (commandArgs) => + persistFrontmatterChange( + String(commandArgs?.path ?? ''), + (content) => replaceFrontmatterEntry(content, String(commandArgs?.key ?? ''), commandArgs?.value), + ), + delete_frontmatter_property: (commandArgs) => + persistFrontmatterChange( + String(commandArgs?.path ?? ''), + (content) => removeFrontmatterEntry(content, String(commandArgs?.key ?? '')), + ), + rename_note: (commandArgs) => + renameNoteRequest({ + vault_path: commandArgs?.vaultPath ?? resolvedVaultPath, + old_path: commandArgs?.oldPath, + new_title: commandArgs?.newTitle, + old_title: commandArgs?.oldTitle ?? null, + }), + rename_note_filename: (commandArgs) => + readJson('/api/vault/rename-filename', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + vault_path: commandArgs?.vaultPath ?? resolvedVaultPath, + old_path: commandArgs?.oldPath, + new_filename_stem: commandArgs?.newFilenameStem, + }), + }), + search_vault: (commandArgs) => { + const resolvedPath = String(commandArgs?.path ?? commandArgs?.vaultPath ?? resolvedVaultPath) + const query = encodeURIComponent(String(commandArgs?.query ?? '')) + const mode = encodeURIComponent(String(commandArgs?.mode ?? 'all')) + return readJson( + `/api/vault/search?vault_path=${encodeURIComponent(resolvedPath)}&query=${query}&mode=${mode}`, + ) + }, + auto_rename_untitled: async (commandArgs) => { + const notePath = String(commandArgs?.notePath ?? '') + const contentData = await readJson( + `/api/vault/content?path=${encodeURIComponent(notePath)}`, + ) as { content: string } + const match = contentData.content.match(/^#\s+(.+)$/m) + if (!match) return null + return renameNoteRequest({ + vault_path: commandArgs?.vaultPath ?? resolvedVaultPath, + old_path: notePath, + new_title: match[1].trim(), + }) + }, + } + const invoke = async (command: string, args?: Record) => { - switch (command) { - case 'trigger_menu_command': { - const commandId = String(args?.id ?? '') - const bridge = window.__laputaTest?.dispatchBrowserMenuCommand - if (!bridge) throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand') - bridge(commandId) - return null - } - case 'load_vault_list': - return { - vaults: [{ label: 'Test Vault', path: resolvedVaultPath }], - active_vault: resolvedVaultPath, - hidden_defaults: [], - } - case 'check_vault_exists': - case 'is_git_repo': - return true - case 'get_last_vault_path': - case 'get_default_vault_path': - return resolvedVaultPath - case 'save_vault_list': - case 'save_settings': - case 'register_mcp_tools': - case 'reinit_telemetry': - case 'update_menu_state': - return null - case 'get_settings': - return { - github_token: null, - github_username: null, - auto_pull_interval_minutes: 5, - telemetry_consent: false, - crash_reporting_enabled: null, - analytics_enabled: null, - anonymous_id: null, - release_channel: null, - } - case 'list_vault': - case 'reload_vault': { - const path = String(args?.path ?? resolvedVaultPath) - return readJson(`/api/vault/list?path=${encodeURIComponent(path)}&reload=${command === 'reload_vault' ? '1' : '0'}`) - } - case 'list_vault_folders': - case 'list_views': - case 'get_modified_files': - case 'detect_renames': - return [] - case 'reload_vault_entry': - return readJson(`/api/vault/entry?path=${encodeURIComponent(String(args?.path ?? ''))}`) - case 'get_note_content': { - const data = await readJson(`/api/vault/content?path=${encodeURIComponent(String(args?.path ?? ''))}`) as { content: string } - return data.content - } - case 'get_all_content': - return readJson(`/api/vault/all-content?path=${encodeURIComponent(String(args?.path ?? resolvedVaultPath))}`) - case 'save_note_content': - return readJson('/api/vault/save', { - method: 'POST', - headers: jsonHeaders, - body: JSON.stringify({ path: args?.path, content: args?.content }), - }) - case 'update_frontmatter': - return persistFrontmatterChange( - String(args?.path ?? ''), - (content) => replaceFrontmatterEntry(content, String(args?.key ?? ''), args?.value), - ) - case 'delete_frontmatter_property': - return persistFrontmatterChange( - String(args?.path ?? ''), - (content) => removeFrontmatterEntry(content, String(args?.key ?? '')), - ) - case 'rename_note': - return readJson('/api/vault/rename', { - method: 'POST', - headers: jsonHeaders, - body: JSON.stringify({ - vault_path: args?.vaultPath ?? resolvedVaultPath, - old_path: args?.oldPath, - new_title: args?.newTitle, - old_title: args?.oldTitle ?? null, - }), - }) - case 'rename_note_filename': - return readJson('/api/vault/rename-filename', { - method: 'POST', - headers: jsonHeaders, - body: JSON.stringify({ - vault_path: args?.vaultPath ?? resolvedVaultPath, - old_path: args?.oldPath, - new_filename_stem: args?.newFilenameStem, - }), - }) - case 'search_vault': { - const path = String(args?.path ?? args?.vaultPath ?? resolvedVaultPath) - const query = encodeURIComponent(String(args?.query ?? '')) - const mode = encodeURIComponent(String(args?.mode ?? 'all')) - return readJson(`/api/vault/search?vault_path=${encodeURIComponent(path)}&query=${query}&mode=${mode}`) - } - case 'auto_rename_untitled': { - const notePath = String(args?.notePath ?? '') - const contentData = await readJson(`/api/vault/content?path=${encodeURIComponent(notePath)}`) as { content: string } - const match = contentData.content.match(/^#\s+(.+)$/m) - if (!match) return null - return readJson('/api/vault/rename', { - method: 'POST', - headers: jsonHeaders, - body: JSON.stringify({ - vault_path: args?.vaultPath ?? resolvedVaultPath, - old_path: notePath, - new_title: match[1].trim(), - }), - }) - } - default: { - const handler = window.__mockHandlers?.[command] - if (!handler) throw new Error(`Unhandled invoke: ${command}`) - return handler(args) - } - } + const handler = commandHandlers[command] ?? window.__mockHandlers?.[command] + if (!handler) throw new Error(`Unhandled invoke: ${command}`) + return handler(args) } Object.defineProperty(window, '__TAURI__', {