Compare commits
4 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2745d96eb | ||
|
|
a2c84d3ce8 | ||
|
|
afbea1367c | ||
|
|
7933fe1bfb |
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
50
docs/adr/0056-system-git-cli-auth-no-provider-oauth.md
Normal file
50
docs/adr/0056-system-git-cli-auth-no-provider-oauth.md
Normal file
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
178
src-tauri/Cargo.lock
generated
178
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
@@ -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<Vec<GithubRepo>, 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<GithubRepo, String> {
|
||||
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<String, String> {
|
||||
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<DeviceFlowStart, String> {
|
||||
crate::github::github_device_flow_start().await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
crate::github::github_device_flow_poll(&device_code).await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
crate::github::github_get_user(&token).await
|
||||
}
|
||||
|
||||
// ── GitHub commands (mobile stubs) ──────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_list_repos(_token: String) -> Result<Vec<GithubRepo>, 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<GithubRepo, String> {
|
||||
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<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(_device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(_token: String) -> Result<GitHubUser, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
163
src-tauri/src/git/clone.rs
Normal file
163
src-tauri/src/git/clone.rs
Normal file
@@ -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<String, String> {
|
||||
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<bool, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
use super::{GitHubUser, GithubRepo};
|
||||
|
||||
/// Lists the authenticated user's GitHub repositories.
|
||||
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, 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<Vec<GithubRepo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut all_repos: Vec<GithubRepo> = 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<GithubRepo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
|
||||
|
||||
let count = repos.len();
|
||||
all_repos.extend(repos);
|
||||
|
||||
if count < 100 {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
if page > 10 {
|
||||
break; // safety limit: 1000 repos max
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_repos)
|
||||
}
|
||||
|
||||
/// Creates a new GitHub repository for the authenticated user.
|
||||
pub async fn github_create_repo(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
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<GithubRepo, String> {
|
||||
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::<GithubRepo>()
|
||||
.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<GitHubUser, String> {
|
||||
github_get_user_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
|
||||
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::<GitHubUser>()
|
||||
.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<Vec<GithubRepo>, 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<GithubRepo, String> {
|
||||
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<GitHubUser, String> {
|
||||
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<serde_json::Value> = (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());
|
||||
}
|
||||
}
|
||||
@@ -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<DeviceFlowStart, String> {
|
||||
github_device_flow_start_with_base("https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
|
||||
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::<DeviceFlowStart>()
|
||||
.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<DeviceFlowPollResult, String> {
|
||||
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<DeviceFlowPollResult, String> {
|
||||
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<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
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<DeviceFlowStart, String> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
|
||||
} else if let Some(rest) = url.strip_prefix("https://") {
|
||||
// Handle URLs that already have a host
|
||||
Ok(format!("https://oauth2:{}@{}", token, rest))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported URL format: {}. Use an HTTPS URL.",
|
||||
url
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub private: bool,
|
||||
pub clone_url: String,
|
||||
pub html_url: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GitHubUser {
|
||||
pub login: String,
|
||||
pub name: Option<String>,
|
||||
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::<GithubRepo>(&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::<DeviceFlowStart>(&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::<DeviceFlowPollResult>(&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::<DeviceFlowPollResult>(&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::<GitHubUser>(&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());
|
||||
}
|
||||
}
|
||||
@@ -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::Wry>) -> tauri::Builder<ta
|
||||
commands::save_settings,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::github_list_repos,
|
||||
commands::github_create_repo,
|
||||
commands::clone_repo,
|
||||
commands::github_device_flow_start,
|
||||
commands::github_device_flow_poll,
|
||||
commands::github_get_user,
|
||||
commands::search_vault,
|
||||
commands::create_empty_vault,
|
||||
commands::create_getting_started_vault,
|
||||
|
||||
@@ -7,8 +7,6 @@ const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
pub github_token: Option<String>,
|
||||
pub github_username: Option<String>,
|
||||
pub auto_pull_interval_minutes: Option<u32>,
|
||||
pub telemetry_consent: Option<bool>,
|
||||
pub crash_reporting_enabled: Option<bool>,
|
||||
@@ -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<u32>,
|
||||
Option<bool>,
|
||||
Option<bool>,
|
||||
Option<bool>,
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
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,
|
||||
|
||||
32
src/App.tsx
32
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'
|
||||
@@ -64,6 +64,7 @@ import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
|
||||
import {
|
||||
INBOX_SELECTION,
|
||||
isExplicitOrganizationEnabled,
|
||||
@@ -97,10 +98,20 @@ function App() {
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
const { showAIChat, toggleAIChat } = dialogs
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
const openFeedback = useCallback(() => setShowFeedback(true), [])
|
||||
const closeFeedback = useCallback(() => setShowFeedback(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenAiChat = () => {
|
||||
if (!showAIChat) toggleAIChat()
|
||||
}
|
||||
|
||||
window.addEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
||||
return () => window.removeEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
||||
}, [showAIChat, toggleAIChat])
|
||||
|
||||
// onSwitch closure captures `notes` declared below — safe because it's only
|
||||
// called on user interaction, never during render (refs inside the hook
|
||||
// guarantee the latest closure is always used).
|
||||
@@ -726,10 +737,16 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => 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} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onClickPending={() => 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} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
entries={vault.entries}
|
||||
claudeCodeReady={claudeCodeStatus === 'installed'}
|
||||
onClose={dialogs.closeCommandPalette}
|
||||
/>
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
|
||||
@@ -747,14 +764,7 @@ function App() {
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<GitHubVaultModal
|
||||
open={dialogs.showGitHubVault}
|
||||
githubToken={settings.github_token}
|
||||
onClose={dialogs.closeGitHubVault}
|
||||
onVaultCloned={vaultSwitcher.handleVaultCloned}
|
||||
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
|
||||
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
|
||||
/>
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
{deleteActions.confirmDelete && (
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
|
||||
@@ -2,16 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt } from '../utils/aiPromptBridge'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
|
||||
const mockSendMessage = vi.fn()
|
||||
const mockClearConversation = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
useAiAgent: () => ({
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
sendMessage: mockSendMessage,
|
||||
clearConversation: mockClearConversation,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -48,6 +52,8 @@ describe('AiPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockMessages = []
|
||||
mockStatus = 'idle'
|
||||
mockSendMessage.mockReset()
|
||||
mockClearConversation.mockReset()
|
||||
})
|
||||
|
||||
it('renders panel with AI Chat header', () => {
|
||||
@@ -114,7 +120,7 @@ describe('AiPanel', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toBeTruthy()
|
||||
expect((input as HTMLInputElement).disabled).toBe(false)
|
||||
expect(input).toHaveAttribute('contenteditable', 'true')
|
||||
})
|
||||
|
||||
it('has send button disabled when input is empty', () => {
|
||||
@@ -128,14 +134,14 @@ describe('AiPanel', () => {
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask about this note...')
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask about this note...')
|
||||
})
|
||||
|
||||
it('shows generic placeholder when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask the AI agent...')
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask the AI agent...')
|
||||
})
|
||||
|
||||
it('auto-focuses input on mount', async () => {
|
||||
@@ -203,4 +209,20 @@ describe('AiPanel', () => {
|
||||
fireEvent.click(wikilinks[1])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
|
||||
})
|
||||
|
||||
it('auto-sends a queued prompt from the command palette bridge', async () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" entries={[makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' })]} />)
|
||||
|
||||
await act(async () => {
|
||||
queueAiPrompt('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockClearConversation).toHaveBeenCalledOnce()
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
expect(screen.getByTestId('agent-send')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import { type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
import { type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
|
||||
import { useAiPanelFocus } from './useAiPanelFocus'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
|
||||
@@ -111,75 +115,89 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
|
||||
)
|
||||
}
|
||||
|
||||
function AiPanelComposer({
|
||||
entries,
|
||||
hasContext,
|
||||
input,
|
||||
inputRef,
|
||||
isActive,
|
||||
onChange,
|
||||
onSend,
|
||||
}: {
|
||||
entries: VaultEntry[]
|
||||
hasContext: boolean
|
||||
input: string
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
isActive: boolean
|
||||
onChange: (value: string) => void
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={isActive}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: pendingRefs.length > 0 ? pendingRefs : undefined,
|
||||
})
|
||||
}, [activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
const {
|
||||
agent,
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
} = useAiPanelController({
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
})
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
} else {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && panelRef.current?.contains(document.activeElement)) {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
setPendingRefs(references)
|
||||
agent.sendMessage(text, references)
|
||||
setInput('')
|
||||
}, [isActive, agent])
|
||||
useAiPanelPromptQueue({ agent, input, isActive, setInput })
|
||||
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -208,39 +226,15 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries ?? []}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
disabled={isActive}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={() => handleSend(input, pendingRefs)}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AiPanelComposer
|
||||
entries={entries ?? []}
|
||||
hasContext={hasContext}
|
||||
input={input}
|
||||
inputRef={inputRef}
|
||||
isActive={isActive}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
85
src/components/CloneVaultModal.test.tsx
Normal file
85
src/components/CloneVaultModal.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CloneVaultModal } from './CloneVaultModal'
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
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(
|
||||
<CloneVaultModal open={false} onClose={onClose} onVaultCloned={onVaultCloned} />
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-testid="clone-vault-modal"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the clone form when open', () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
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(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
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(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
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(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
204
src/components/CloneVaultModal.tsx
Normal file
204
src/components/CloneVaultModal.tsx
Normal file
@@ -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<void>
|
||||
}
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(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<CloneStatus>('idle')
|
||||
const [cloneError, setCloneError] = useState<string | null>(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<string>('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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[520px]" data-testid="clone-vault-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clone Git Repo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Clone any remote repository into a local vault folder. Tolaria uses your existing system git
|
||||
configuration for authentication.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="clone-repo-url">Repository URL</label>
|
||||
<Input
|
||||
id="clone-repo-url"
|
||||
placeholder="git@host:owner/repo.git or https://host/owner/repo.git"
|
||||
value={repoUrl}
|
||||
onChange={(event) => handleRepoUrlChange(event.target.value)}
|
||||
data-testid="clone-repo-url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="clone-vault-path">Clone to</label>
|
||||
<Input
|
||||
id="clone-vault-path"
|
||||
placeholder="~/Vaults/my-vault"
|
||||
value={localPath}
|
||||
onChange={(event) => handleLocalPathChange(event.target.value)}
|
||||
data-testid="clone-vault-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
SSH keys, the git credential manager, `gh auth`, and other system git auth methods all work.
|
||||
</p>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive" data-testid="clone-vault-error">{cloneError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
|
||||
<Button
|
||||
onClick={handleClone}
|
||||
disabled={isCloneDisabled}
|
||||
data-testid="clone-vault-submit"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import type { CommandAction } from '../hooks/useCommandRegistry'
|
||||
|
||||
// jsdom doesn't implement scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
vi.mock('../utils/aiPromptBridge', () => ({
|
||||
queueAiPrompt: vi.fn(),
|
||||
requestOpenAiChat: vi.fn(),
|
||||
}))
|
||||
|
||||
const makeCommand = (overrides: Partial<CommandAction> = {}): CommandAction => ({
|
||||
id: 'test-cmd',
|
||||
label: 'Test Command',
|
||||
@@ -25,6 +32,59 @@ const commands: CommandAction[] = [
|
||||
makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }),
|
||||
]
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' }),
|
||||
]
|
||||
|
||||
function setSelection(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const targetNode = editor.firstChild ?? editor
|
||||
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
|
||||
? Math.min(offset, targetNode.textContent?.length ?? 0)
|
||||
: Math.min(offset, targetNode.childNodes.length)
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(targetNode, safeOffset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function updateAiInput(text: string) {
|
||||
const editor = screen.getByTestId('command-palette-ai-input')
|
||||
editor.textContent = text
|
||||
setSelection(editor, text.length)
|
||||
fireEvent.input(editor)
|
||||
return editor
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
@@ -166,6 +226,54 @@ describe('CommandPalette', () => {
|
||||
expect(screen.getByText('esc close')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches into AI mode when the query starts with a leading space', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
|
||||
expect(screen.getByTestId('command-palette-ai-input')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Ask Claude Code').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to command mode when the leading space is deleted', () => {
|
||||
render(
|
||||
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
updateAiInput('new')
|
||||
|
||||
const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement
|
||||
expect(screen.queryByTestId('command-palette-ai-input')).toBeNull()
|
||||
expect(input.value).toBe('new')
|
||||
expect(screen.getByText('New Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('queues a stripped AI prompt and closes on Enter in AI mode', () => {
|
||||
render(
|
||||
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
const editor = updateAiInput(' hello world')
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
expect(queueAiPrompt).toHaveBeenCalledWith('hello world', [])
|
||||
expect(requestOpenAiChat).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes without queueing when AI mode only contains the trigger space', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
fireEvent.keyDown(screen.getByTestId('command-palette-ai-input'), { key: 'Enter' })
|
||||
|
||||
expect(queueAiPrompt).not.toHaveBeenCalled()
|
||||
expect(requestOpenAiChat).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('relevance ranking', () => {
|
||||
const relevanceCommands: CommandAction[] = [
|
||||
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note' }),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { fuzzyMatch } from '../utils/fuzzyMatch'
|
||||
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
|
||||
import { groupSortKey } from '../hooks/useCommandRegistry'
|
||||
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
commands: CommandAction[]
|
||||
entries?: VaultEntry[]
|
||||
claudeCodeReady?: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -15,17 +21,17 @@ interface ScoredCommand {
|
||||
score: number
|
||||
}
|
||||
|
||||
function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null {
|
||||
const labelResult = fuzzyMatch(query, cmd.label)
|
||||
if (labelResult.match) return { command: cmd, score: labelResult.score }
|
||||
function matchCommand(query: string, command: CommandAction): ScoredCommand | null {
|
||||
const labelResult = fuzzyMatch(query, command.label)
|
||||
if (labelResult.match) return { command, score: labelResult.score }
|
||||
|
||||
for (const kw of cmd.keywords ?? []) {
|
||||
const kwResult = fuzzyMatch(query, kw)
|
||||
if (kwResult.match) return { command: cmd, score: kwResult.score - 1 }
|
||||
for (const keyword of command.keywords ?? []) {
|
||||
const keywordResult = fuzzyMatch(query, keyword)
|
||||
if (keywordResult.match) return { command, score: keywordResult.score - 1 }
|
||||
}
|
||||
|
||||
const groupResult = fuzzyMatch(query, cmd.group)
|
||||
if (groupResult.match) return { command: cmd, score: groupResult.score - 2 }
|
||||
const groupResult = fuzzyMatch(query, command.group)
|
||||
if (groupResult.match) return { command, score: groupResult.score - 2 }
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -34,83 +40,262 @@ function groupResults(
|
||||
commands: CommandAction[],
|
||||
byRelevance: boolean,
|
||||
): { group: CommandGroup; items: CommandAction[] }[] {
|
||||
const map = new Map<CommandGroup, CommandAction[]>()
|
||||
for (const cmd of commands) {
|
||||
const list = map.get(cmd.group)
|
||||
if (list) list.push(cmd)
|
||||
else map.set(cmd.group, [cmd])
|
||||
const groupedCommands = new Map<CommandGroup, CommandAction[]>()
|
||||
|
||||
for (const command of commands) {
|
||||
const existing = groupedCommands.get(command.group)
|
||||
if (existing) {
|
||||
existing.push(command)
|
||||
continue
|
||||
}
|
||||
groupedCommands.set(command.group, [command])
|
||||
}
|
||||
const entries = Array.from(map.entries())
|
||||
|
||||
const entries = Array.from(groupedCommands.entries())
|
||||
if (!byRelevance) {
|
||||
entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
|
||||
entries.sort((left, right) => groupSortKey(left[0]) - groupSortKey(right[0]))
|
||||
}
|
||||
|
||||
return entries.map(([group, items]) => ({ group, items }))
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
|
||||
setSelectedIndex(0)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function usePaletteResults(commands: CommandAction[], query: string) {
|
||||
const enabledCommands = useMemo(
|
||||
() => commands.filter(c => c.enabled),
|
||||
() => commands.filter((command) => command.enabled),
|
||||
[commands],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!query.trim()) return enabledCommands
|
||||
return enabledCommands
|
||||
.map(cmd => matchCommand(query, cmd))
|
||||
.filter((r): r is ScoredCommand => r !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(r => r.command)
|
||||
.map((command) => matchCommand(query, command))
|
||||
.filter((result): result is ScoredCommand => result !== null)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.map((result) => result.command)
|
||||
}, [enabledCommands, query])
|
||||
|
||||
const hasQuery = !!query.trim()
|
||||
const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery])
|
||||
const flatList = useMemo(() => groups.flatMap(g => g.items), [groups])
|
||||
const hasQuery = query.trim().length > 0
|
||||
const groups = useMemo(
|
||||
() => groupResults(filteredCommands, hasQuery),
|
||||
[filteredCommands, hasQuery],
|
||||
)
|
||||
|
||||
return {
|
||||
groups,
|
||||
flatList: groups.flatMap((group) => group.items),
|
||||
}
|
||||
}
|
||||
|
||||
function CommandPaletteInput({
|
||||
inputRef,
|
||||
query,
|
||||
onChange,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
query: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteResults({
|
||||
groups,
|
||||
selectedIndex,
|
||||
listRef,
|
||||
onHover,
|
||||
onSelect,
|
||||
}: {
|
||||
groups: { group: CommandGroup; items: CommandAction[] }[]
|
||||
selectedIndex: number
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
onHover: (index: number) => void
|
||||
onSelect: (command: CommandAction) => void
|
||||
}) {
|
||||
const flatList = groups.flatMap((group) => group.items)
|
||||
|
||||
if (flatList.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
No matching commands
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = groups.reduce<Array<{ group: CommandGroup; items: CommandAction[]; startIndex: number }>>(
|
||||
(acc, group) => {
|
||||
const previous = acc.at(-1)
|
||||
acc.push({
|
||||
...group,
|
||||
startIndex: previous ? previous.startIndex + previous.items.length : 0,
|
||||
})
|
||||
return acc
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
{sections.map(({ group, items, startIndex }) => {
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((command, index) => {
|
||||
const globalIndex = startIndex + index
|
||||
return (
|
||||
<CommandRow
|
||||
key={command.id}
|
||||
command={command}
|
||||
selected={globalIndex === selectedIndex}
|
||||
onHover={() => onHover(globalIndex)}
|
||||
onSelect={() => onSelect(command)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteFooter({
|
||||
aiMode,
|
||||
}: {
|
||||
aiMode: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{aiMode ? 'Claude mode' : '↑↓ navigate'}</span>
|
||||
<span>{aiMode ? '↵ send' : '↵ select'}</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, ...props }: CommandPaletteProps) {
|
||||
if (!open) return null
|
||||
return <OpenCommandPalette {...props} />
|
||||
}
|
||||
|
||||
function OpenCommandPalette({
|
||||
commands,
|
||||
entries = [],
|
||||
claudeCodeReady = true,
|
||||
onClose,
|
||||
}: Omit<CommandPaletteProps, 'open'>) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [aiValue, setAiValue] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const aiInputRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const aiMode = aiValue.startsWith(' ')
|
||||
const { groups, flatList } = usePaletteResults(commands, query)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change
|
||||
}, [query])
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
if (aiMode) {
|
||||
aiInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
inputRef.current?.focus()
|
||||
}, 50)
|
||||
return () => window.clearTimeout(focusTimer)
|
||||
}, [aiMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const el = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | undefined
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex])
|
||||
if (aiMode || !listRef.current) return
|
||||
const selectedElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null
|
||||
selectedElement?.scrollIntoView({ block: 'nearest' })
|
||||
}, [aiMode, selectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault(); onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, flatList.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const cmd = flatList[selectedIndex]
|
||||
if (cmd) { onClose(); cmd.execute() }
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (aiMode) return
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((current) => Math.min(current + 1, flatList.length - 1))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((current) => Math.max(current - 1, 0))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const command = flatList[selectedIndex]
|
||||
if (!command) return
|
||||
onClose()
|
||||
command.execute()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [open, flatList, selectedIndex, onClose])
|
||||
|
||||
if (!open) return null
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [aiMode, flatList, onClose, selectedIndex])
|
||||
|
||||
let runningIndex = 0
|
||||
const handleQueryChange = (nextQuery: string) => {
|
||||
setSelectedIndex(0)
|
||||
if (nextQuery.startsWith(' ')) {
|
||||
setAiValue(nextQuery)
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
|
||||
setQuery(nextQuery)
|
||||
}
|
||||
|
||||
const handleAiValueChange = (nextValue: string) => {
|
||||
setSelectedIndex(0)
|
||||
if (nextValue.startsWith(' ')) {
|
||||
setAiValue(nextValue)
|
||||
return
|
||||
}
|
||||
|
||||
setAiValue('')
|
||||
setQuery(nextValue)
|
||||
}
|
||||
|
||||
const handleSelectCommand = (command: CommandAction) => {
|
||||
onClose()
|
||||
command.execute()
|
||||
}
|
||||
|
||||
const handleSubmitAiPrompt = (text: string, references: NoteReference[]) => {
|
||||
if (!text.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (!claudeCodeReady) return
|
||||
|
||||
queueAiPrompt(text, references)
|
||||
requestOpenAiChat()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -118,53 +303,33 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex w-[520px] max-w-[90vw] max-h-[440px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={cn(
|
||||
'flex w-[520px] max-h-[440px] max-w-[90vw] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]',
|
||||
aiMode && 'min-h-[220px]',
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
{flatList.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
No matching commands
|
||||
</div>
|
||||
) : (
|
||||
groups.map(({ group, items }) => {
|
||||
const startIndex = runningIndex
|
||||
runningIndex += items.length
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((cmd, i) => {
|
||||
const globalIdx = startIndex + i
|
||||
return (
|
||||
<CommandRow
|
||||
key={cmd.id}
|
||||
command={cmd}
|
||||
selected={globalIdx === selectedIndex}
|
||||
onHover={() => setSelectedIndex(globalIdx)}
|
||||
onSelect={() => { onClose(); cmd.execute() }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>↑↓ navigate</span>
|
||||
<span>↵ select</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
{aiMode ? (
|
||||
<CommandPaletteAiMode
|
||||
entries={entries}
|
||||
value={aiValue}
|
||||
claudeCodeReady={claudeCodeReady}
|
||||
onChange={handleAiValueChange}
|
||||
onSubmit={handleSubmitAiPrompt}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CommandPaletteInput inputRef={inputRef} query={query} onChange={handleQueryChange} />
|
||||
<CommandPaletteResults
|
||||
groups={groups}
|
||||
selectedIndex={selectedIndex}
|
||||
listRef={listRef}
|
||||
onHover={setSelectedIndex}
|
||||
onSelect={handleSelectCommand}
|
||||
/>
|
||||
<CommandPaletteFooter aiMode={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
67
src/components/CommandPaletteAiMode.tsx
Normal file
67
src/components/CommandPaletteAiMode.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Sparkle } from '@phosphor-icons/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { InlineWikilinkInput } from './InlineWikilinkInput'
|
||||
|
||||
interface CommandPaletteAiModeProps {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
claudeCodeReady: boolean
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (text: string, references: NoteReference[]) => void
|
||||
}
|
||||
|
||||
function stripLeadingSpace(value: string): string {
|
||||
return value.startsWith(' ') ? value.slice(1) : value
|
||||
}
|
||||
|
||||
export function CommandPaletteAiMode({
|
||||
entries,
|
||||
value,
|
||||
claudeCodeReady,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: CommandPaletteAiModeProps) {
|
||||
return (
|
||||
<InlineWikilinkInput
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={(text, references) => onSubmit(stripLeadingSpace(text), references)}
|
||||
submitOnEmpty={true}
|
||||
placeholder="Ask Claude Code..."
|
||||
dataTestId="command-palette-ai-input"
|
||||
editorClassName="border-none px-0 py-0 text-[15px]"
|
||||
suggestionListVariant="palette"
|
||||
paletteHeader={(
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<Sparkle size={12} weight="fill" />
|
||||
<span>Ask Claude Code</span>
|
||||
</div>
|
||||
)}
|
||||
paletteEmptyState={(
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
{!claudeCodeReady ? (
|
||||
'Claude Code is not available on this machine.'
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 font-medium text-foreground">Ask Claude Code</div>
|
||||
<div>
|
||||
{value.trim().length === 0
|
||||
? 'Type your prompt after the leading space.'
|
||||
: 'Type [[ to insert a note reference inline.'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
paletteFooter={(
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>Claude mode</span>
|
||||
<span>↵ send</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(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<void>
|
||||
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<OAuthStatus>('idle')
|
||||
const [userCode, setUserCode] = useState<string | null>(null)
|
||||
const [verificationUri, setVerificationUri] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const pollingRef = useRef(false)
|
||||
const deviceCodeRef = useRef<string | null>(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<DeviceFlowStart>('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<DeviceFlowPollResult>('github_device_flow_poll', {
|
||||
deviceCode: deviceCodeRef.current,
|
||||
})
|
||||
const outcome = processPollResult(result, {
|
||||
onComplete: async (token) => {
|
||||
const user = await tauriCall<GitHubUser>('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 <DeviceCodeView userCode={userCode} verificationUri={verificationUri} onCancel={resetOAuth} />
|
||||
}
|
||||
|
||||
return <LoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} onRetry={errorMessage ? () => { 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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} data-testid="github-waiting">
|
||||
<div
|
||||
className="border border-border rounded px-4 py-3"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 8, textAlign: 'center' }}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Enter this code on GitHub:</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
style={{ fontSize: 24, fontWeight: 700, letterSpacing: 4, color: 'var(--foreground)', fontFamily: 'monospace' }}
|
||||
data-testid="github-user-code"
|
||||
>
|
||||
{userCode}
|
||||
</div>
|
||||
<button
|
||||
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={handleCopyCode}
|
||||
title="Copy code"
|
||||
data-testid="github-copy-code"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{verificationUri && (
|
||||
<button
|
||||
className="border-none bg-transparent text-muted-foreground cursor-pointer hover:text-foreground underline"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={handleOpenUrl}
|
||||
data-testid="github-open-url"
|
||||
>
|
||||
{verificationUri}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-2" style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>
|
||||
<CircleNotch size={14} className="animate-spin" />
|
||||
Waiting for authorization...
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', alignSelf: 'center' }}
|
||||
onClick={onCancel}
|
||||
data-testid="github-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
className="border-none rounded cursor-pointer flex items-center justify-center gap-2"
|
||||
style={{ fontSize: 13, fontWeight: 500, padding: '8px 16px', background: 'var(--foreground)', color: 'var(--background)', height: 36 }}
|
||||
onClick={onLogin}
|
||||
disabled={disabled}
|
||||
data-testid="github-login"
|
||||
>
|
||||
<GithubLogo size={16} weight="fill" />
|
||||
Login with GitHub
|
||||
</button>
|
||||
{errorMessage && (
|
||||
<div className="flex items-center gap-2" style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)' }}>
|
||||
<span data-testid="github-error">{errorMessage}</span>
|
||||
{onRetry && (
|
||||
<button
|
||||
className="border-none bg-transparent cursor-pointer hover:text-foreground flex items-center gap-1"
|
||||
style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)', padding: 0 }}
|
||||
onClick={onRetry}
|
||||
data-testid="github-retry"
|
||||
>
|
||||
<ArrowClockwise size={12} />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<GitHubVaultModal open={false} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
expect(container.querySelector('[data-testid="github-vault-modal"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows settings fallback when no token and no onGitHubConnected', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
expect(screen.getByText(/Add your GitHub token in Settings/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('github-open-settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens settings when "Open Settings" clicked without token', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('github-open-settings'))
|
||||
expect(onOpenSettings).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows inline device flow when no token and onGitHubConnected is provided', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
|
||||
)
|
||||
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(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
|
||||
)
|
||||
|
||||
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(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
expect(screen.getByTestId('github-tab-clone')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('github-tab-create')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads and displays repos in clone tab', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('user/public-notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('A personal vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters repos by search', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('github-repo-search'), { target: { value: 'public' } })
|
||||
|
||||
expect(screen.queryByText('user/my-vault')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('user/public-notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects a repo and auto-fills clone path', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
|
||||
|
||||
const pathInput = screen.getByTestId('github-clone-path') as HTMLInputElement
|
||||
expect(pathInput.value).toBe('~/Vaults/my-vault')
|
||||
})
|
||||
|
||||
it('clone button disabled without selection', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('github-clone-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('github-clone-btn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls clone_repo and onVaultCloned on clone', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
|
||||
fireEvent.click(screen.getByTestId('github-clone-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault')
|
||||
})
|
||||
})
|
||||
|
||||
it('has create tab trigger that is clickable', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
const createTab = screen.getByTestId('github-tab-create')
|
||||
expect(createTab).toBeInTheDocument()
|
||||
expect(createTab).toHaveTextContent('Create New')
|
||||
expect(createTab).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows error when clone fails', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_list_repos') return MOCK_REPOS
|
||||
if (cmd === 'clone_repo') throw new Error('Permission denied')
|
||||
throw new Error(`Unknown: ${cmd}`)
|
||||
})
|
||||
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
|
||||
fireEvent.click(screen.getByTestId('github-clone-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Clone failed/i)).toBeInTheDocument()
|
||||
})
|
||||
expect(onVaultCloned).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows Private/Public badges on repos', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Private')).toBeInTheDocument()
|
||||
expect(screen.getByText('Public')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
interface GitHubVaultModalProps {
|
||||
open: boolean
|
||||
githubToken: string | null
|
||||
onClose: () => void
|
||||
onVaultCloned: (path: string, label: string) => void
|
||||
onOpenSettings: () => void
|
||||
onGitHubConnected?: (token: string, username: string) => void
|
||||
}
|
||||
|
||||
type CloneStatus = 'idle' | 'cloning' | 'success' | 'error'
|
||||
|
||||
function RepoListItem({ repo, selected, onSelect }: { repo: GithubRepo; selected: boolean; onSelect: () => void }) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }}
|
||||
className={`flex flex-col gap-1 rounded-md px-3 py-2.5 cursor-pointer transition-colors ${
|
||||
selected ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
data-testid={`repo-item-${repo.name}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{repo.full_name}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{repo.private ? 'Private' : 'Public'}
|
||||
</Badge>
|
||||
</div>
|
||||
{repo.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{repo.description}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CloneTab({
|
||||
repos,
|
||||
loading,
|
||||
search,
|
||||
setSearch,
|
||||
selectedRepo,
|
||||
setSelectedRepo,
|
||||
localPath,
|
||||
setLocalPath,
|
||||
onClone,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
}: {
|
||||
repos: GithubRepo[]
|
||||
loading: boolean
|
||||
search: string
|
||||
setSearch: (v: string) => void
|
||||
selectedRepo: GithubRepo | null
|
||||
setSelectedRepo: (r: GithubRepo) => void
|
||||
localPath: string
|
||||
setLocalPath: (v: string) => void
|
||||
onClone: () => void
|
||||
cloneStatus: CloneStatus
|
||||
cloneError: string | null
|
||||
}) {
|
||||
const filtered = repos.filter(r =>
|
||||
r.full_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(r.description?.toLowerCase().includes(search.toLowerCase()) ?? false)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 min-h-0">
|
||||
<Input
|
||||
placeholder="Search repos or paste URL..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
data-testid="github-repo-search"
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border border-border rounded-md min-h-[180px] max-h-[240px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
Loading repositories...
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
{search ? 'No repos match your search' : 'No repositories found'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 p-1">
|
||||
{filtered.map(repo => (
|
||||
<RepoListItem
|
||||
key={repo.full_name}
|
||||
repo={repo}
|
||||
selected={selectedRepo?.full_name === repo.full_name}
|
||||
onSelect={() => setSelectedRepo(repo)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">Clone to:</label>
|
||||
<Input
|
||||
value={localPath}
|
||||
onChange={e => setLocalPath(e.target.value)}
|
||||
placeholder="~/Vaults/repo-name"
|
||||
data-testid="github-clone-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive">{cloneError}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{filtered.length} repo{filtered.length !== 1 ? 's' : ''} found
|
||||
</span>
|
||||
<Button
|
||||
onClick={onClone}
|
||||
disabled={!selectedRepo || !localPath.trim() || cloneStatus === 'cloning'}
|
||||
data-testid="github-clone-btn"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTab({
|
||||
repoName,
|
||||
setRepoName,
|
||||
isPrivate,
|
||||
setIsPrivate,
|
||||
localPath,
|
||||
setLocalPath,
|
||||
onCreate,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
}: {
|
||||
repoName: string
|
||||
setRepoName: (v: string) => void
|
||||
isPrivate: boolean
|
||||
setIsPrivate: (v: boolean) => void
|
||||
localPath: string
|
||||
setLocalPath: (v: string) => void
|
||||
onCreate: () => void
|
||||
cloneStatus: CloneStatus
|
||||
cloneError: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">Repository name</label>
|
||||
<Input
|
||||
value={repoName}
|
||||
onChange={e => setRepoName(e.target.value)}
|
||||
placeholder="my-vault"
|
||||
data-testid="github-repo-name"
|
||||
/>
|
||||
{repoName && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
github.com/you/{repoName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-medium text-foreground">Visibility</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPrivate(true)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border transition-colors ${
|
||||
isPrivate
|
||||
? 'border-ring bg-accent text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
data-testid="github-visibility-private"
|
||||
>
|
||||
Private
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPrivate(false)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border transition-colors ${
|
||||
!isPrivate
|
||||
? 'border-ring bg-accent text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
data-testid="github-visibility-public"
|
||||
>
|
||||
Public
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">Clone to:</label>
|
||||
<Input
|
||||
value={localPath}
|
||||
onChange={e => setLocalPath(e.target.value)}
|
||||
placeholder="~/Vaults/my-vault"
|
||||
data-testid="github-create-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive">{cloneError}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
|
||||
<Button
|
||||
onClick={onCreate}
|
||||
disabled={!repoName.trim() || !localPath.trim() || cloneStatus === 'cloning'}
|
||||
data-testid="github-create-btn"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Creating...' : 'Create & Clone'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CloningProgress({ repoName }: { repoName: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8" data-testid="clone-progress">
|
||||
<div className="h-10 w-10 rounded-full border-[3px] border-ring border-t-transparent animate-spin" />
|
||||
<p className="text-sm font-medium text-foreground">Cloning {repoName}...</p>
|
||||
<p className="text-xs text-muted-foreground">This may take a moment for large repositories</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings, onGitHubConnected }: GitHubVaultModalProps) {
|
||||
const [tab, setTab] = useState('clone')
|
||||
const [repos, setRepos] = useState<GithubRepo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedRepo, setSelectedRepo] = useState<GithubRepo | null>(null)
|
||||
const [clonePath, setClonePath] = useState('')
|
||||
const [repoName, setRepoName] = useState('')
|
||||
const [isPrivate, setIsPrivate] = useState(true)
|
||||
const [createPath, setCreatePath] = useState('')
|
||||
const [cloneStatus, setCloneStatus] = useState<CloneStatus>('idle')
|
||||
const [cloneError, setCloneError] = useState<string | null>(null)
|
||||
const loadedRef = useRef(false)
|
||||
|
||||
const loadRepos = useCallback(async () => {
|
||||
if (!githubToken) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await tauriCall<GithubRepo[]>('github_list_repos', { token: githubToken })
|
||||
setRepos(result)
|
||||
} catch (err) {
|
||||
console.error('Failed to load GitHub repos:', err)
|
||||
setCloneError(`Failed to load repos: ${err}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [githubToken])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && githubToken && !loadedRef.current) {
|
||||
loadedRef.current = true
|
||||
loadRepos()
|
||||
}
|
||||
if (!open) {
|
||||
loadedRef.current = false
|
||||
}
|
||||
}, [open, githubToken, loadRepos])
|
||||
|
||||
// Auto-fill clone path when repo selected
|
||||
useEffect(() => {
|
||||
if (selectedRepo) {
|
||||
setClonePath(`~/Vaults/${selectedRepo.name}`)
|
||||
}
|
||||
}, [selectedRepo])
|
||||
|
||||
// Auto-fill create path when name changes
|
||||
useEffect(() => {
|
||||
if (repoName) {
|
||||
setCreatePath(`~/Vaults/${repoName}`)
|
||||
}
|
||||
}, [repoName])
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setTab('clone')
|
||||
setSearch('')
|
||||
setSelectedRepo(null)
|
||||
setClonePath('')
|
||||
setRepoName('')
|
||||
setIsPrivate(true)
|
||||
setCreatePath('')
|
||||
setCloneStatus('idle')
|
||||
setCloneError(null)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [onClose, resetState])
|
||||
|
||||
const handleClone = useCallback(async () => {
|
||||
if (!selectedRepo || !clonePath.trim() || !githubToken) return
|
||||
setCloneStatus('cloning')
|
||||
setCloneError(null)
|
||||
try {
|
||||
await tauriCall<string>('clone_repo', {
|
||||
url: selectedRepo.clone_url,
|
||||
token: githubToken,
|
||||
localPath: clonePath.trim(),
|
||||
})
|
||||
setCloneStatus('success')
|
||||
onVaultCloned(clonePath.trim(), selectedRepo.name)
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
setCloneStatus('error')
|
||||
setCloneError(`Clone failed: ${err}`)
|
||||
}
|
||||
}, [selectedRepo, clonePath, githubToken, onVaultCloned, handleClose])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!repoName.trim() || !createPath.trim() || !githubToken) return
|
||||
setCloneStatus('cloning')
|
||||
setCloneError(null)
|
||||
try {
|
||||
const repo = await tauriCall<GithubRepo>('github_create_repo', {
|
||||
token: githubToken,
|
||||
name: repoName.trim(),
|
||||
private: isPrivate,
|
||||
})
|
||||
await tauriCall<string>('clone_repo', {
|
||||
url: repo.clone_url,
|
||||
token: githubToken,
|
||||
localPath: createPath.trim(),
|
||||
})
|
||||
setCloneStatus('success')
|
||||
onVaultCloned(createPath.trim(), repo.name)
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
setCloneStatus('error')
|
||||
setCloneError(`${err}`)
|
||||
}
|
||||
}, [repoName, createPath, githubToken, isPrivate, onVaultCloned, handleClose])
|
||||
|
||||
if (!githubToken) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isOpen => { if (!isOpen) handleClose() }}>
|
||||
<DialogContent className="sm:max-w-[480px]" data-testid="github-vault-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect GitHub Repo</DialogTitle>
|
||||
<DialogDescription>Connect your GitHub account to clone or create vaults backed by GitHub repos.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
{onGitHubConnected ? (
|
||||
<GitHubDeviceFlow onConnected={onGitHubConnected} />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
You need to connect your GitHub account first. Add your GitHub token in Settings.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => { handleClose(); onOpenSettings() }}
|
||||
data-testid="github-open-settings"
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const cloningRepoName = tab === 'clone' ? (selectedRepo?.full_name ?? '') : repoName
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isOpen => { if (!isOpen) handleClose() }}>
|
||||
<DialogContent className="sm:max-w-[540px]" data-testid="github-vault-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect GitHub Repo</DialogTitle>
|
||||
<DialogDescription>Clone an existing repo or create a new one as a vault.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{cloneStatus === 'cloning' ? (
|
||||
<CloningProgress repoName={cloningRepoName} />
|
||||
) : (
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="clone" data-testid="github-tab-clone">Clone Existing</TabsTrigger>
|
||||
<TabsTrigger value="create" data-testid="github-tab-create">Create New</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="clone">
|
||||
<CloneTab
|
||||
repos={repos}
|
||||
loading={loading}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
selectedRepo={selectedRepo}
|
||||
setSelectedRepo={setSelectedRepo}
|
||||
localPath={clonePath}
|
||||
setLocalPath={setClonePath}
|
||||
onClone={handleClone}
|
||||
cloneStatus={cloneStatus}
|
||||
cloneError={cloneError}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="create">
|
||||
<CreateTab
|
||||
repoName={repoName}
|
||||
setRepoName={setRepoName}
|
||||
isPrivate={isPrivate}
|
||||
setIsPrivate={setIsPrivate}
|
||||
localPath={createPath}
|
||||
setLocalPath={setCreatePath}
|
||||
onCreate={handleCreate}
|
||||
cloneStatus={cloneStatus}
|
||||
cloneError={cloneError}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
198
src/components/InlineWikilinkInput.tsx
Normal file
198
src/components/InlineWikilinkInput.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import {
|
||||
buildInlineWikilinkSegments,
|
||||
extractInlineWikilinkReferences,
|
||||
findActiveWikilinkQuery,
|
||||
findInlineChipDeletionRange,
|
||||
} from './inlineWikilinkText'
|
||||
import {
|
||||
InlineWikilinkEditorField,
|
||||
InlineWikilinkPaletteLayout,
|
||||
InlineWikilinkSuggestionList,
|
||||
} from './InlineWikilinkParts'
|
||||
import { handleInlineWikilinkKeyDown } from './inlineWikilinkKeydown'
|
||||
import { useInlineWikilinkSelection } from './useInlineWikilinkSelection'
|
||||
import { useInlineWikilinkSuggestionsState } from './useInlineWikilinkSuggestionsState'
|
||||
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
|
||||
|
||||
interface InlineWikilinkInputProps {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit?: (text: string, references: NoteReference[]) => void
|
||||
submitOnEmpty?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
dataTestId?: string
|
||||
editorClassName?: string
|
||||
suggestionListVariant?: 'floating' | 'palette'
|
||||
suggestionEmptyLabel?: string
|
||||
paletteHeader?: ReactNode
|
||||
paletteEmptyState?: ReactNode
|
||||
paletteFooter?: ReactNode
|
||||
}
|
||||
|
||||
function deleteInlineChip({
|
||||
direction,
|
||||
segments,
|
||||
selectionIndex,
|
||||
value,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
}: {
|
||||
direction: 'backward' | 'forward'
|
||||
segments: ReturnType<typeof buildInlineWikilinkSegments>
|
||||
selectionIndex: number
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSelectionIndexChange: (selectionIndex: number) => void
|
||||
}) {
|
||||
const deletionRange = findInlineChipDeletionRange(segments, selectionIndex, direction)
|
||||
if (!deletionRange) return false
|
||||
|
||||
onChange(value.slice(0, deletionRange.start) + value.slice(deletionRange.end))
|
||||
onSelectionIndexChange(deletionRange.start)
|
||||
return true
|
||||
}
|
||||
|
||||
function submitInlineValue({
|
||||
onSubmit,
|
||||
submitOnEmpty,
|
||||
value,
|
||||
references,
|
||||
}: {
|
||||
onSubmit?: (text: string, references: NoteReference[]) => void
|
||||
submitOnEmpty: boolean
|
||||
value: string
|
||||
references: NoteReference[]
|
||||
}) {
|
||||
if (!onSubmit) return
|
||||
const normalizedValue = normalizeInlineWikilinkValue(value)
|
||||
if (!submitOnEmpty && !normalizedValue.trim()) return
|
||||
onSubmit(normalizedValue, references)
|
||||
}
|
||||
|
||||
export function InlineWikilinkInput({
|
||||
entries,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
submitOnEmpty = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
inputRef,
|
||||
dataTestId = 'agent-input',
|
||||
editorClassName,
|
||||
suggestionListVariant = 'floating',
|
||||
suggestionEmptyLabel = 'No matching notes',
|
||||
paletteHeader,
|
||||
paletteEmptyState,
|
||||
paletteFooter,
|
||||
}: InlineWikilinkInputProps) {
|
||||
const segments = useMemo(
|
||||
() => buildInlineWikilinkSegments(value, entries),
|
||||
[entries, value],
|
||||
)
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const {
|
||||
selectionIndex,
|
||||
setSelectionIndex,
|
||||
setCombinedRef,
|
||||
syncSelectionIndex,
|
||||
commitValueFromEditor,
|
||||
focusSelectionAt,
|
||||
} = useInlineWikilinkSelection({
|
||||
value,
|
||||
onChange,
|
||||
inputRef,
|
||||
})
|
||||
const activeQuery = useMemo(
|
||||
() => findActiveWikilinkQuery(value, selectionIndex),
|
||||
[selectionIndex, value],
|
||||
)
|
||||
const references = useMemo(() => extractInlineWikilinkReferences(value, entries), [entries, value])
|
||||
const {
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
setSuggestionIndex,
|
||||
selectSuggestion,
|
||||
cycleSuggestions,
|
||||
} = useInlineWikilinkSuggestionsState({
|
||||
activeQueryKey: activeQuery ? `${activeQuery.start}:${activeQuery.query}` : '',
|
||||
entries,
|
||||
query: activeQuery?.query ?? null,
|
||||
value,
|
||||
selectionIndex,
|
||||
onChange,
|
||||
onSelectionIndexChange: setSelectionIndex,
|
||||
focusSelectionAt,
|
||||
})
|
||||
const deleteAdjacentChip = (direction: 'backward' | 'forward') => {
|
||||
return deleteInlineChip({
|
||||
direction,
|
||||
segments,
|
||||
selectionIndex,
|
||||
value,
|
||||
onChange,
|
||||
onSelectionIndexChange: setSelectionIndex,
|
||||
})
|
||||
}
|
||||
const submitValue = () =>
|
||||
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
handleInlineWikilinkKeyDown({
|
||||
event,
|
||||
disabled,
|
||||
suggestionsOpen: suggestions.length > 0,
|
||||
onCycleSuggestions: cycleSuggestions,
|
||||
onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex),
|
||||
onDeleteAdjacentChip: deleteAdjacentChip,
|
||||
canSubmit: onSubmit !== undefined,
|
||||
onSubmit: submitValue,
|
||||
})
|
||||
const editor = (
|
||||
<InlineWikilinkEditorField
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
inputRef={setCombinedRef}
|
||||
dataTestId={dataTestId}
|
||||
editorClassName={editorClassName}
|
||||
onInput={commitValueFromEditor}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSelectionChange={syncSelectionIndex}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
const suggestionList = suggestions.length > 0 ? (
|
||||
<InlineWikilinkSuggestionList
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selectedSuggestionIndex}
|
||||
onHover={setSuggestionIndex}
|
||||
onSelect={selectSuggestion}
|
||||
typeEntryMap={typeEntryMap}
|
||||
variant={suggestionListVariant}
|
||||
emptyLabel={suggestionEmptyLabel}
|
||||
/>
|
||||
) : null
|
||||
if (suggestionListVariant === 'palette') {
|
||||
return (
|
||||
<InlineWikilinkPaletteLayout
|
||||
header={paletteHeader}
|
||||
editor={editor}
|
||||
suggestionList={suggestionList}
|
||||
emptyState={paletteEmptyState}
|
||||
footer={paletteFooter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div className="relative">{editor}{suggestionList}</div>
|
||||
}
|
||||
251
src/components/InlineWikilinkParts.tsx
Normal file
251
src/components/InlineWikilinkParts.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { createElement } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { getTypeIcon } from './note-item/typeIcon'
|
||||
import type {
|
||||
InlineWikilinkChip,
|
||||
InlineWikilinkSegment,
|
||||
} from './inlineWikilinkText'
|
||||
import type { InlineWikilinkSuggestion } from './inlineWikilinkSuggestions'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function InlineWikilinkChipView({
|
||||
chip,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
chip: InlineWikilinkChip
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const typeEntry = chip.entry.isA ? typeEntryMap[chip.entry.isA] : undefined
|
||||
const color = getTypeColor(chip.entry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(chip.entry.isA, typeEntry?.color)
|
||||
const typeIcon = getTypeIcon(chip.entry.isA, typeEntry?.icon)
|
||||
|
||||
return (
|
||||
<span
|
||||
contentEditable={false}
|
||||
data-chip-target={chip.target}
|
||||
data-testid="inline-wikilink-chip"
|
||||
className="mx-[1px] inline-flex max-w-full items-center gap-1 rounded-full align-baseline"
|
||||
style={{
|
||||
backgroundColor,
|
||||
color,
|
||||
padding: '1px 8px 1px 6px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{chip.entry.icon ? (
|
||||
<NoteTitleIcon icon={chip.entry.icon} size={11} color={color} />
|
||||
) : (
|
||||
createElement(typeIcon, {
|
||||
'aria-hidden': true,
|
||||
width: 11,
|
||||
height: 11,
|
||||
className: 'shrink-0',
|
||||
})
|
||||
)}
|
||||
<span className="truncate">{chip.entry.title}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineSuggestionRow({
|
||||
suggestion,
|
||||
selected,
|
||||
onHover,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
suggestion: InlineWikilinkSuggestion
|
||||
selected: boolean
|
||||
onHover: () => void
|
||||
onSelect: () => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const typeEntry = suggestion.entry.isA ? typeEntryMap[suggestion.entry.isA] : undefined
|
||||
const color = getTypeColor(suggestion.entry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(suggestion.entry.isA, typeEntry?.color)
|
||||
const typeIcon = getTypeIcon(suggestion.entry.isA, typeEntry?.icon)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-1 flex cursor-pointer items-center justify-between rounded-md px-3 py-2 transition-colors',
|
||||
selected ? 'bg-accent' : 'hover:bg-secondary',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onHover}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor, color }}
|
||||
>
|
||||
{suggestion.entry.icon ? (
|
||||
<NoteTitleIcon icon={suggestion.entry.icon} size={11} color={color} />
|
||||
) : (
|
||||
createElement(typeIcon, {
|
||||
'aria-hidden': true,
|
||||
width: 11,
|
||||
height: 11,
|
||||
className: 'shrink-0',
|
||||
})
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-foreground">{suggestion.title}</span>
|
||||
</div>
|
||||
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
|
||||
{suggestion.entry.isA ?? 'Note'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkSuggestionList({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onHover,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
variant = 'floating',
|
||||
emptyLabel = 'No matching notes',
|
||||
}: {
|
||||
suggestions: InlineWikilinkSuggestion[]
|
||||
selectedIndex: number
|
||||
onHover: (index: number) => void
|
||||
onSelect: (index: number) => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
variant?: 'floating' | 'palette'
|
||||
emptyLabel?: string
|
||||
}) {
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-5 text-center text-[13px] text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={variant === 'floating'
|
||||
? 'absolute bottom-full left-0 right-0 z-10 mb-1 max-h-64 overflow-y-auto rounded-lg border border-border bg-popover py-1 shadow-lg'
|
||||
: 'py-1'}
|
||||
data-testid="wikilink-menu"
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<InlineSuggestionRow
|
||||
key={`${suggestion.entry.path}:${suggestion.target}`}
|
||||
suggestion={suggestion}
|
||||
selected={index === selectedIndex}
|
||||
onHover={() => onHover(index)}
|
||||
onSelect={() => onSelect(index)}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkEditorField({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
inputRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
value: string
|
||||
placeholder?: string
|
||||
disabled: boolean
|
||||
inputRef: React.Ref<HTMLDivElement>
|
||||
dataTestId: string
|
||||
editorClassName?: string
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: InlineWikilinkSegment[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{value.length === 0 && placeholder && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-muted-foreground"
|
||||
style={{ padding: '8px 10px', fontSize: 13 }}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={inputRef}
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning={true}
|
||||
role="textbox"
|
||||
aria-multiline="false"
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-placeholder={placeholder}
|
||||
data-testid={dataTestId}
|
||||
className={cn(
|
||||
'min-h-[34px] w-full rounded-lg border border-border bg-transparent px-[10px] py-[8px] text-[13px] text-foreground outline-none',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
editorClassName,
|
||||
)}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={onSelectionChange}
|
||||
onKeyUp={onSelectionChange}
|
||||
onMouseUp={onSelectionChange}
|
||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
segment.kind === 'text'
|
||||
? <span key={`text-${index}`}>{segment.text}</span>
|
||||
: (
|
||||
<InlineWikilinkChipView
|
||||
key={`chip-${segment.chip.entry.path}-${segment.chip.target}`}
|
||||
chip={segment.chip}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkPaletteLayout({
|
||||
header,
|
||||
editor,
|
||||
suggestionList,
|
||||
emptyState,
|
||||
footer,
|
||||
}: {
|
||||
header?: React.ReactNode
|
||||
editor: React.ReactNode
|
||||
suggestionList: React.ReactNode
|
||||
emptyState?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
{header}
|
||||
{editor}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{suggestionList ?? emptyState}
|
||||
</div>
|
||||
{footer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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', () => {
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByTestId('github-login')).toBeInTheDocument()
|
||||
expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show GitHub token input field', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
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(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.queryByText('GitHub')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Connect your GitHub account/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Privacy & Telemetry section', () => {
|
||||
|
||||
@@ -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 <GitHubConnectedRow username={githubUsername!} onDisconnect={onDisconnect} />
|
||||
}
|
||||
|
||||
return <GitHubDeviceFlow onConnected={onConnected} />
|
||||
}
|
||||
|
||||
function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDisconnect: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div
|
||||
className="flex items-center gap-2 border border-border rounded px-3 py-2 flex-1"
|
||||
style={{ minHeight: 36 }}
|
||||
data-testid="github-connected"
|
||||
>
|
||||
<GithubLogo size={16} weight="fill" style={{ color: 'var(--foreground)' }} />
|
||||
<span style={{ fontSize: 13, color: 'var(--foreground)', fontWeight: 500 }}>{username}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Connected</span>
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground hover:border-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
onClick={onDisconnect}
|
||||
title="Disconnect GitHub account"
|
||||
data-testid="github-disconnect"
|
||||
>
|
||||
<SignOut size={14} />
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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({
|
||||
>
|
||||
<SettingsHeader onClose={onClose} />
|
||||
<SettingsBody
|
||||
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
|
||||
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
|
||||
pullInterval={pullInterval} setPullInterval={setPullInterval}
|
||||
releaseChannel={releaseChannel} setReleaseChannel={setReleaseChannel}
|
||||
explicitOrganization={explicitOrganization}
|
||||
@@ -208,9 +143,6 @@ function SettingsHeader({ onClose }: { onClose: () => 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 (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20, overflow: 'auto' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>GitHub</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Connect your GitHub account to clone and sync vaults.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GitHubSection
|
||||
githubUsername={props.githubUsername}
|
||||
githubToken={props.githubToken}
|
||||
onConnected={props.onGitHubConnected}
|
||||
onDisconnect={props.onGitHubDisconnect}
|
||||
/>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Sync</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -260,9 +260,9 @@ export function WelcomeScreen({
|
||||
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
|
||||
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' && (
|
||||
<p style={STATUS_STYLE} data-testid="welcome-status" role="status" aria-live="polite">
|
||||
Downloading the Getting Started vault from GitHub…
|
||||
Downloading the Getting Started vault template…
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -31,249 +31,154 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/alpha.md', title: 'Alpha', filename: 'alpha.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person' }),
|
||||
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person', aliases: ['BLT'] }),
|
||||
makeEntry({ path: '/vault/gamma.md', title: 'Gamma', filename: 'gamma.md' }),
|
||||
makeEntry({ path: '/vault/archived.md', title: 'Archived', filename: 'archived.md', archived: true }),
|
||||
]
|
||||
|
||||
/** Wrapper that manages controlled value state so onChange/selectSuggestion work correctly. */
|
||||
function Controlled({ onSend, ...props }: {
|
||||
entries: VaultEntry[]
|
||||
function Controlled({
|
||||
onSend,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
}: {
|
||||
onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<WikilinkChatInput
|
||||
entries={props.entries}
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSend={onSend ?? vi.fn()}
|
||||
disabled={props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Helper: type in the input and wait for debounce to flush. */
|
||||
async function typeAndWait(text: string) {
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: text, selectionStart: text.length },
|
||||
})
|
||||
await act(() => { vi.advanceTimersByTime(150) })
|
||||
function setSelection(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const targetNode = editor.firstChild ?? editor
|
||||
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
|
||||
? Math.min(offset, targetNode.textContent?.length ?? 0)
|
||||
: Math.min(offset, targetNode.childNodes.length)
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(targetNode, safeOffset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function updateEditorText(text: string) {
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
fireEvent.focus(editor)
|
||||
editor.textContent = text
|
||||
setSelection(editor, text.length)
|
||||
fireEvent.input(editor)
|
||||
}
|
||||
|
||||
function clickFirstSuggestion() {
|
||||
const rows = screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
fireEvent.click(rows[0])
|
||||
}
|
||||
|
||||
describe('WikilinkChatInput', () => {
|
||||
it('renders input with placeholder', () => {
|
||||
render(<Controlled entries={entries} placeholder="Ask something..." />)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask something...')
|
||||
it('renders the placeholder overlay for an empty draft', () => {
|
||||
render(<Controlled placeholder="Ask something..." />)
|
||||
expect(screen.getByText('Ask something...')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask something...')
|
||||
})
|
||||
|
||||
it('calls onChange when typing', () => {
|
||||
it('calls onChange when the draft changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="" onChange={onChange} onSend={vi.fn()} />,
|
||||
)
|
||||
fireEvent.change(screen.getByTestId('agent-input'), { target: { value: 'hello' } })
|
||||
|
||||
updateEditorText('hello')
|
||||
expect(onChange).toHaveBeenCalledWith('hello')
|
||||
})
|
||||
|
||||
it('shows wikilink menu when [[ is typed', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('shows wikilink suggestions after typing [[', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('[[a')
|
||||
|
||||
it('filters suggestions matching query', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Alpha')
|
||||
expect(menu.textContent).not.toContain('Beta')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('excludes archived entries from suggestions', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[arch')
|
||||
const menu = screen.queryByTestId('wikilink-menu')
|
||||
if (menu) {
|
||||
expect(menu.textContent).not.toContain('Archived')
|
||||
}
|
||||
vi.useRealTimers()
|
||||
it('matches suggestions by alias', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('[[BLT')
|
||||
|
||||
expect(screen.getByTestId('wikilink-menu').textContent).toContain('Beta')
|
||||
})
|
||||
|
||||
it('selects suggestion on click and creates pill', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
it('renders selected wikilinks inline instead of in a separate pill strip', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('edit my [[alp')
|
||||
clickFirstSuggestion()
|
||||
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
const items = menu.querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
fireEvent.click(items[0])
|
||||
|
||||
const pills = screen.queryAllByTestId('reference-pill')
|
||||
expect(pills.length).toBe(1)
|
||||
vi.useRealTimers()
|
||||
expect(screen.queryByTestId('reference-pill')).toBeNull()
|
||||
expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-input').textContent).toContain('Alpha')
|
||||
})
|
||||
|
||||
it('does not duplicate pills for same note', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
|
||||
// First selection
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
// Second selection of same note
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1) // No duplicate
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('removes pill when x button is clicked', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
const pill = screen.getByTestId('reference-pill')
|
||||
fireEvent.click(pill.querySelector('button')!)
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('navigates suggestions with keyboard', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
|
||||
const input = screen.getByTestId('agent-input')
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'ArrowUp' })
|
||||
// Escape closes menu
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('selects suggestion with Enter key', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
it('selects a suggestion with Enter before sending the draft', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled onSend={onSend} />)
|
||||
updateEditorText('edit my [[alp')
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument()
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSend with text and references on Enter without menu', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled entries={entries} onSend={onSend} />)
|
||||
it('deletes an inline chip with a single Backspace', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('edit my [[alp')
|
||||
clickFirstSuggestion()
|
||||
|
||||
// Type non-wikilink text
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: 'hello', selectionStart: 5 },
|
||||
})
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
fireEvent.keyDown(editor, { key: 'Backspace' })
|
||||
|
||||
expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull()
|
||||
})
|
||||
|
||||
it('submits serialized wikilink text and resolved references', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled onSend={onSend} />)
|
||||
|
||||
updateEditorText('edit my [[alpha]] essay')
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(onSend).toHaveBeenCalledWith('hello', [])
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('edit my [[alpha]] essay', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not send on Enter+Shift', () => {
|
||||
it('does not send on Shift+Enter', () => {
|
||||
const onSend = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="hello" onChange={vi.fn()} onSend={onSend} />,
|
||||
)
|
||||
render(<Controlled onSend={onSend} />)
|
||||
|
||||
updateEditorText('hello')
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not send on Enter when input is empty', () => {
|
||||
const onSend = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="" onChange={vi.fn()} onSend={onSend} />,
|
||||
)
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
it('marks the editor disabled when disabled is true', () => {
|
||||
render(<Controlled disabled />)
|
||||
|
||||
it('disables input when disabled prop is true', () => {
|
||||
render(<Controlled entries={entries} disabled />)
|
||||
expect((screen.getByTestId('agent-input') as HTMLInputElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes menu when ]] is typed', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
|
||||
// Type ]] to close
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: '[[alp]]', selectionStart: 7 },
|
||||
})
|
||||
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows type badge for non-Note types in suggestions', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Project')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('matches by alias', async () => {
|
||||
vi.useFakeTimers()
|
||||
const entriesWithAlias = [
|
||||
...entries,
|
||||
makeEntry({ path: '/vault/delta.md', title: 'Delta', filename: 'delta.md', aliases: ['DLT'] }),
|
||||
]
|
||||
render(<Controlled entries={entriesWithAlias} />)
|
||||
await typeAndWait('[[DLT')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Delta')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sends references with onSend after selecting pills', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled entries={entries} onSend={onSend} />)
|
||||
|
||||
// Select a pill first
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
// Type a message and send
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: 'tell me about it', selectionStart: 16 },
|
||||
})
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
|
||||
expect(onSend).toHaveBeenCalledOnce()
|
||||
const [text, refs] = onSend.mock.calls[0]
|
||||
expect(text).toBe('tell me about it')
|
||||
expect(refs).toHaveLength(1)
|
||||
expect(refs[0].title).toBe('Alpha')
|
||||
expect(refs[0].path).toBe('/vault/alpha.md')
|
||||
vi.useRealTimers()
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
expect(editor).toHaveAttribute('contenteditable', 'false')
|
||||
expect(editor).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
/**
|
||||
* Chat input with [[wikilink]] autocomplete.
|
||||
*
|
||||
* When the user types `[[`, a dropdown appears with vault note suggestions.
|
||||
* Selecting a note inserts a colored pill in the input and records a reference.
|
||||
*/
|
||||
import { useState, useRef, useMemo, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { bestSearchRank } from '../utils/fuzzyMatch'
|
||||
|
||||
const MAX_SUGGESTIONS = 20
|
||||
const MIN_QUERY_LENGTH = 1
|
||||
const DEBOUNCE_MS = 100
|
||||
import { InlineWikilinkInput } from './InlineWikilinkInput'
|
||||
|
||||
interface WikilinkChatInputProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -21,248 +9,27 @@ interface WikilinkChatInputProps {
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
interface Pill {
|
||||
title: string
|
||||
path: string
|
||||
type: string | null
|
||||
color?: string
|
||||
lightColor?: string
|
||||
}
|
||||
|
||||
interface SuggestionEntry {
|
||||
title: string
|
||||
path: string
|
||||
isA: string | null
|
||||
color?: string
|
||||
lightColor?: string
|
||||
}
|
||||
|
||||
function matchEntries(
|
||||
entries: VaultEntry[],
|
||||
query: string,
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): SuggestionEntry[] {
|
||||
if (query.length < MIN_QUERY_LENGTH) return []
|
||||
const lower = query.toLowerCase()
|
||||
const matches = entries
|
||||
.filter(e =>
|
||||
!e.archived && (
|
||||
e.title.toLowerCase().includes(lower) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lower))
|
||||
),
|
||||
)
|
||||
.map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) }))
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return {
|
||||
title: e.title,
|
||||
path: e.path,
|
||||
isA: e.isA,
|
||||
color: e.isA ? getTypeColor(e.isA, te?.color) : undefined,
|
||||
lightColor: e.isA ? getTypeLightColor(e.isA, te?.color) : undefined,
|
||||
}
|
||||
})
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function WikilinkChatInput({
|
||||
entries, value, onChange, onSend, disabled, placeholder, inputRef: externalRef,
|
||||
entries,
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
inputRef,
|
||||
}: WikilinkChatInputProps) {
|
||||
const [pills, setPills] = useState<Pill[]>([])
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const internalRef = useRef<HTMLInputElement>(null)
|
||||
const inputRefToUse = externalRef ?? internalRef
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => showMenu ? matchEntries(entries, debouncedQuery, typeEntryMap) : [],
|
||||
[entries, debouncedQuery, typeEntryMap, showMenu],
|
||||
)
|
||||
|
||||
// Clamp selection to valid range
|
||||
const clampedIndex = suggestions.length > 0
|
||||
? Math.min(selectedIndex, suggestions.length - 1)
|
||||
: 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuRef.current || clampedIndex < 0) return
|
||||
const el = menuRef.current.children[clampedIndex] as HTMLElement | undefined
|
||||
el?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [clampedIndex])
|
||||
|
||||
function selectSuggestion(suggestion: SuggestionEntry) {
|
||||
const cursor = inputRefToUse.current?.selectionStart ?? value.length
|
||||
const textBefore = value.slice(0, cursor)
|
||||
const bracketIdx = textBefore.lastIndexOf('[[')
|
||||
if (bracketIdx < 0) return
|
||||
|
||||
const textAfter = value.slice(cursor)
|
||||
const newValue = textBefore.slice(0, bracketIdx) + textAfter
|
||||
|
||||
onChange(newValue)
|
||||
setPills(prev => {
|
||||
if (prev.some(p => p.path === suggestion.path)) return prev
|
||||
return [...prev, {
|
||||
title: suggestion.title,
|
||||
path: suggestion.path,
|
||||
type: suggestion.isA,
|
||||
color: suggestion.color,
|
||||
lightColor: suggestion.lightColor,
|
||||
}]
|
||||
})
|
||||
setShowMenu(false)
|
||||
setTimeout(() => inputRefToUse.current?.focus(), 0)
|
||||
}
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const newValue = e.target.value
|
||||
onChange(newValue)
|
||||
|
||||
const cursor = e.target.selectionStart ?? newValue.length
|
||||
const textBefore = newValue.slice(0, cursor)
|
||||
const bracketIdx = textBefore.lastIndexOf('[[')
|
||||
|
||||
if (bracketIdx >= 0 && !textBefore.slice(bracketIdx).includes(']]')) {
|
||||
const query = textBefore.slice(bracketIdx + 2)
|
||||
setShowMenu(true)
|
||||
setSelectedIndex(0)
|
||||
clearTimeout(debounceTimer.current)
|
||||
debounceTimer.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS)
|
||||
} else {
|
||||
setShowMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (showMenu && suggestions.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i + 1) % suggestions.length)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1))
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
selectSuggestion(suggestions[clampedIndex])
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setShowMenu(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!value.trim() && pills.length === 0) return
|
||||
const references: NoteReference[] = pills.map(p => ({
|
||||
title: p.title,
|
||||
path: p.path,
|
||||
type: p.type,
|
||||
}))
|
||||
onSend(value, references)
|
||||
setPills([])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{pills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
|
||||
{pills.map(pill => (
|
||||
<span
|
||||
key={pill.path}
|
||||
className="inline-flex items-center gap-1 text-xs"
|
||||
style={{
|
||||
background: pill.lightColor ?? 'var(--muted)',
|
||||
color: pill.color ?? 'var(--foreground)',
|
||||
borderRadius: 9999,
|
||||
padding: '1px 8px 1px 6px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
data-testid="reference-pill"
|
||||
>
|
||||
{pill.title}
|
||||
<button
|
||||
className="border-none bg-transparent p-0 cursor-pointer"
|
||||
style={{ color: 'inherit', opacity: 0.6, fontSize: 10, lineHeight: 1 }}
|
||||
onClick={() => setPills(prev => prev.filter(p => p.path !== pill.path))}
|
||||
tabIndex={-1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={inputRefToUse}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 border border-border bg-transparent text-foreground"
|
||||
style={{
|
||||
fontSize: 13, borderRadius: 8, padding: '8px 10px',
|
||||
outline: 'none', fontFamily: 'inherit', width: '100%', boxSizing: 'border-box',
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
data-testid="agent-input"
|
||||
/>
|
||||
{showMenu && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="wikilink-menu"
|
||||
style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, right: 0,
|
||||
marginBottom: 4, maxHeight: 260, overflowY: 'auto',
|
||||
}}
|
||||
data-testid="wikilink-menu"
|
||||
>
|
||||
{suggestions.map((s, i) => (
|
||||
<div
|
||||
key={s.path}
|
||||
className="flex items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 13,
|
||||
background: i === clampedIndex ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => selectSuggestion(s)}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
>
|
||||
<span className="truncate">{s.title}</span>
|
||||
{s.isA && s.isA !== 'Note' && (
|
||||
<span
|
||||
className="shrink-0 text-xs"
|
||||
style={{
|
||||
color: s.color,
|
||||
backgroundColor: s.lightColor,
|
||||
borderRadius: 9999,
|
||||
padding: '1px 8px',
|
||||
}}
|
||||
>
|
||||
{s.isA}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InlineWikilinkInput
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSend}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
134
src/components/inlineWikilinkDom.ts
Normal file
134
src/components/inlineWikilinkDom.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
chipToken,
|
||||
normalizeInlineWikilinkValue,
|
||||
} from './inlineWikilinkTokens'
|
||||
|
||||
export function serializeInlineNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return normalizeInlineWikilinkValue(node.textContent ?? '')
|
||||
}
|
||||
|
||||
if (node instanceof HTMLElement) {
|
||||
if (node.dataset.chipTarget) {
|
||||
return chipToken(node.dataset.chipTarget)
|
||||
}
|
||||
|
||||
if (node.tagName === 'BR') return ''
|
||||
}
|
||||
|
||||
return Array.from(node.childNodes).map(serializeInlineNode).join('')
|
||||
}
|
||||
|
||||
function selectionFallsOutsideEditor(
|
||||
selection: Selection | null,
|
||||
root: HTMLDivElement,
|
||||
): boolean {
|
||||
return (
|
||||
!selection ||
|
||||
selection.rangeCount === 0 ||
|
||||
!selection.anchorNode ||
|
||||
!root.contains(selection.anchorNode)
|
||||
)
|
||||
}
|
||||
|
||||
export function readSelectionIndex(root: HTMLDivElement): number {
|
||||
const currentSelection = window.getSelection()
|
||||
if (
|
||||
!currentSelection ||
|
||||
selectionFallsOutsideEditor(currentSelection, root)
|
||||
) {
|
||||
return serializeInlineNode(root).length
|
||||
}
|
||||
|
||||
const range = currentSelection.getRangeAt(0).cloneRange()
|
||||
range.setStart(root, 0)
|
||||
return serializeInlineNode(range.cloneContents()).length
|
||||
}
|
||||
|
||||
export function applySelectionIndex(
|
||||
root: HTMLDivElement,
|
||||
selectionIndex: number,
|
||||
) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const range = document.createRange()
|
||||
const clampedIndex = Math.max(0, selectionIndex)
|
||||
const found = placeRangeAtIndex(root, clampedIndex, range)
|
||||
|
||||
if (!found) {
|
||||
range.selectNodeContents(root)
|
||||
range.collapse(false)
|
||||
}
|
||||
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function placeRangeAtIndex(
|
||||
node: Node,
|
||||
selectionIndex: number,
|
||||
range: Range,
|
||||
): boolean {
|
||||
let remaining = selectionIndex
|
||||
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const textPlacement = placeRangeInTextNode(child, remaining, range)
|
||||
if (textPlacement.placed) return true
|
||||
remaining = textPlacement.remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (!(child instanceof HTMLElement)) continue
|
||||
|
||||
if (child.dataset.chipTarget) {
|
||||
const chipPlacement = placeRangeAroundChip(child, remaining, range)
|
||||
if (chipPlacement.placed) return true
|
||||
remaining = chipPlacement.remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (placeRangeAtIndex(child, remaining, range)) return true
|
||||
remaining -= serializeInlineNode(child).length
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function placeRangeInTextNode(
|
||||
node: Node,
|
||||
remaining: number,
|
||||
range: Range,
|
||||
) {
|
||||
const textLength = normalizeInlineWikilinkValue(node.textContent ?? '').length
|
||||
if (remaining <= textLength) {
|
||||
range.setStart(node, remaining)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
return { placed: false, remaining: remaining - textLength }
|
||||
}
|
||||
|
||||
function placeRangeAroundChip(
|
||||
node: HTMLElement,
|
||||
remaining: number,
|
||||
range: Range,
|
||||
) {
|
||||
const tokenLength = chipToken(node.dataset.chipTarget ?? '').length
|
||||
|
||||
if (remaining <= 0) {
|
||||
range.setStartBefore(node)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
if (remaining <= tokenLength) {
|
||||
range.setStartAfter(node)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
return { placed: false, remaining: remaining - tokenLength }
|
||||
}
|
||||
117
src/components/inlineWikilinkKeydown.ts
Normal file
117
src/components/inlineWikilinkKeydown.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type React from 'react'
|
||||
|
||||
interface HandleSuggestionKeysArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
suggestionsOpen: boolean
|
||||
onCycleSuggestions: (direction: 1 | -1) => void
|
||||
onSelectSuggestion: () => void
|
||||
}
|
||||
|
||||
function handleSuggestionKeys({
|
||||
event,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
}: HandleSuggestionKeysArgs): boolean {
|
||||
if (!suggestionsOpen) return false
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
onCycleSuggestions(1)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
onCycleSuggestions(-1)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onSelectSuggestion()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
interface HandleDeleteKeysArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
|
||||
}
|
||||
|
||||
function handleDeleteKeys({
|
||||
event,
|
||||
onDeleteAdjacentChip,
|
||||
}: HandleDeleteKeysArgs): boolean {
|
||||
if (event.key === 'Backspace' && onDeleteAdjacentChip('backward')) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Delete' && onDeleteAdjacentChip('forward')) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
interface HandleSubmitKeyArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
canSubmit: boolean
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
function handleSubmitKey({
|
||||
event,
|
||||
canSubmit,
|
||||
onSubmit,
|
||||
}: HandleSubmitKeyArgs): boolean {
|
||||
if (!canSubmit) return false
|
||||
if (event.key !== 'Enter' || event.shiftKey) return false
|
||||
|
||||
event.preventDefault()
|
||||
onSubmit()
|
||||
return true
|
||||
}
|
||||
|
||||
interface HandleInlineWikilinkKeyDownArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
disabled: boolean
|
||||
suggestionsOpen: boolean
|
||||
onCycleSuggestions: (direction: 1 | -1) => void
|
||||
onSelectSuggestion: () => void
|
||||
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
|
||||
canSubmit: boolean
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export function handleInlineWikilinkKeyDown({
|
||||
event,
|
||||
disabled,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
onDeleteAdjacentChip,
|
||||
canSubmit,
|
||||
onSubmit,
|
||||
}: HandleInlineWikilinkKeyDownArgs) {
|
||||
if (disabled) return
|
||||
|
||||
if (handleSuggestionKeys({
|
||||
event,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleDeleteKeys({ event, onDeleteAdjacentChip })) {
|
||||
return
|
||||
}
|
||||
|
||||
handleSubmitKey({ event, canSubmit, onSubmit })
|
||||
}
|
||||
78
src/components/inlineWikilinkSuggestions.ts
Normal file
78
src/components/inlineWikilinkSuggestions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
MAX_RESULTS,
|
||||
deduplicateByPath,
|
||||
disambiguateTitles,
|
||||
preFilterWikilinks,
|
||||
type WikilinkBaseItem,
|
||||
} from '../utils/wikilinkSuggestions'
|
||||
import { toInlineWikilinkTarget } from './inlineWikilinkTokens'
|
||||
|
||||
export interface InlineWikilinkSuggestion {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface SuggestionItem extends WikilinkBaseItem {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
}
|
||||
|
||||
function toSuggestionItems(entries: VaultEntry[]): SuggestionItem[] {
|
||||
return entries
|
||||
.filter((entry) => !entry.archived)
|
||||
.map((entry) => ({
|
||||
entry,
|
||||
target: toInlineWikilinkTarget(entry),
|
||||
title: entry.title,
|
||||
aliases: entry.aliases,
|
||||
group: entry.isA ?? 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))
|
||||
}
|
||||
|
||||
function toSuggestion(item: SuggestionItem): InlineWikilinkSuggestion {
|
||||
return {
|
||||
entry: item.entry,
|
||||
target: item.target,
|
||||
title: item.title,
|
||||
}
|
||||
}
|
||||
|
||||
function matchSingleCharacterQuery(
|
||||
items: SuggestionItem[],
|
||||
query: string,
|
||||
): SuggestionItem[] {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return items.filter((item) =>
|
||||
item.title.toLowerCase().includes(lowerQuery) ||
|
||||
item.aliases.some((alias) => alias.toLowerCase().includes(lowerQuery)) ||
|
||||
item.group.toLowerCase().includes(lowerQuery) ||
|
||||
item.path.toLowerCase().includes(lowerQuery),
|
||||
)
|
||||
}
|
||||
|
||||
function buildTopSuggestions(items: SuggestionItem[]): InlineWikilinkSuggestion[] {
|
||||
return [...items]
|
||||
.sort((left, right) => left.title.localeCompare(right.title))
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map(toSuggestion)
|
||||
}
|
||||
|
||||
export function buildInlineWikilinkSuggestions(
|
||||
entries: VaultEntry[],
|
||||
query: string,
|
||||
): InlineWikilinkSuggestion[] {
|
||||
const items = toSuggestionItems(entries)
|
||||
if (query.length === 0) return buildTopSuggestions(items)
|
||||
|
||||
const matchedItems = query.length === 1
|
||||
? matchSingleCharacterQuery(items, query)
|
||||
: preFilterWikilinks(items, query)
|
||||
|
||||
return disambiguateTitles(deduplicateByPath(matchedItems))
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map(toSuggestion)
|
||||
}
|
||||
149
src/components/inlineWikilinkText.ts
Normal file
149
src/components/inlineWikilinkText.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import {
|
||||
chipToken,
|
||||
normalizeInlineWikilinkValue,
|
||||
toInlineWikilinkTarget,
|
||||
} from './inlineWikilinkTokens'
|
||||
|
||||
export interface InlineWikilinkChip {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
}
|
||||
|
||||
export type InlineWikilinkSegment =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'chip'; chip: InlineWikilinkChip }
|
||||
|
||||
export interface ActiveWikilinkQuery {
|
||||
start: number
|
||||
query: string
|
||||
}
|
||||
|
||||
const INLINE_WIKILINK_PATTERN = /\[\[([^[\]\r\n]+?)\]\]/g
|
||||
|
||||
export function buildInlineWikilinkSegments(
|
||||
value: string,
|
||||
entries: VaultEntry[],
|
||||
): InlineWikilinkSegment[] {
|
||||
const normalizedValue = normalizeInlineWikilinkValue(value)
|
||||
const segments: InlineWikilinkSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
INLINE_WIKILINK_PATTERN.lastIndex = 0
|
||||
for (const match of normalizedValue.matchAll(INLINE_WIKILINK_PATTERN)) {
|
||||
const fullMatch = match[0]
|
||||
const target = match[1]
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
segments.push({ kind: 'text', text: normalizedValue.slice(cursor, start) })
|
||||
}
|
||||
|
||||
const entry = resolveEntry(entries, target)
|
||||
if (!entry) {
|
||||
segments.push({ kind: 'text', text: fullMatch })
|
||||
} else {
|
||||
segments.push({
|
||||
kind: 'chip',
|
||||
chip: { entry, target: toInlineWikilinkTarget(entry) },
|
||||
})
|
||||
}
|
||||
cursor = start + fullMatch.length
|
||||
}
|
||||
|
||||
if (cursor < normalizedValue.length) {
|
||||
segments.push({ kind: 'text', text: normalizedValue.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ kind: 'text', text: '' }]
|
||||
}
|
||||
|
||||
export function extractInlineWikilinkReferences(
|
||||
value: string,
|
||||
entries: VaultEntry[],
|
||||
): NoteReference[] {
|
||||
const references: NoteReference[] = []
|
||||
const seenPaths = new Set<string>()
|
||||
|
||||
for (const segment of buildInlineWikilinkSegments(value, entries)) {
|
||||
if (segment.kind !== 'chip') continue
|
||||
if (seenPaths.has(segment.chip.entry.path)) continue
|
||||
|
||||
seenPaths.add(segment.chip.entry.path)
|
||||
references.push({
|
||||
title: segment.chip.entry.title,
|
||||
path: segment.chip.entry.path,
|
||||
type: segment.chip.entry.isA,
|
||||
})
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
function hasClosedQuery(openText: string): boolean {
|
||||
return openText.includes(']]') || /[\r\n]/.test(openText)
|
||||
}
|
||||
|
||||
export function findActiveWikilinkQuery(
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
): ActiveWikilinkQuery | null {
|
||||
const clampedIndex = Math.max(0, Math.min(selectionIndex, value.length))
|
||||
const textBeforeCursor = value.slice(0, clampedIndex)
|
||||
const triggerStart = textBeforeCursor.lastIndexOf('[[')
|
||||
|
||||
if (triggerStart < 0) return null
|
||||
|
||||
const openText = textBeforeCursor.slice(triggerStart + 2)
|
||||
if (hasClosedQuery(openText)) return null
|
||||
|
||||
return { start: triggerStart, query: openText }
|
||||
}
|
||||
|
||||
export function replaceActiveWikilinkQuery(
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
target: string,
|
||||
): { value: string; nextSelectionIndex: number } | null {
|
||||
const activeQuery = findActiveWikilinkQuery(value, selectionIndex)
|
||||
if (!activeQuery) return null
|
||||
|
||||
const token = chipToken(target)
|
||||
return {
|
||||
value: value.slice(0, activeQuery.start) + token + value.slice(selectionIndex),
|
||||
nextSelectionIndex: activeQuery.start + token.length,
|
||||
}
|
||||
}
|
||||
|
||||
function segmentLength(segment: InlineWikilinkSegment): number {
|
||||
return segment.kind === 'text'
|
||||
? segment.text.length
|
||||
: chipToken(segment.chip.target).length
|
||||
}
|
||||
|
||||
export function findInlineChipDeletionRange(
|
||||
segments: InlineWikilinkSegment[],
|
||||
selectionIndex: number,
|
||||
direction: 'backward' | 'forward',
|
||||
): { start: number; end: number } | null {
|
||||
let cursor = 0
|
||||
|
||||
for (const segment of segments) {
|
||||
const nextCursor = cursor + segmentLength(segment)
|
||||
|
||||
if (segment.kind === 'chip') {
|
||||
const removePreviousChip = direction === 'backward' && selectionIndex === nextCursor
|
||||
const removeNextChip = direction === 'forward' && selectionIndex === cursor
|
||||
|
||||
if (removePreviousChip || removeNextChip) {
|
||||
return { start: cursor, end: nextCursor }
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
16
src/components/inlineWikilinkTokens.ts
Normal file
16
src/components/inlineWikilinkTokens.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export function toInlineWikilinkTarget(entry: VaultEntry): string {
|
||||
return entry.filename.replace(/\.md$/i, '')
|
||||
}
|
||||
|
||||
export function chipToken(target: string): string {
|
||||
return `[[${target}]]`
|
||||
}
|
||||
|
||||
export function normalizeInlineWikilinkValue(value: string): string {
|
||||
return value
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.replace(/\u200B/g, '')
|
||||
.replace(/\r?\n/g, ' ')
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
|
||||
@@ -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: <Github size={12} />,
|
||||
label: 'Connect GitHub repo',
|
||||
testId: 'vault-menu-connect-github',
|
||||
accent: !hasGitHub,
|
||||
onClick: onConnectGitHub,
|
||||
key: 'clone-git',
|
||||
icon: <GitBranch size={12} />,
|
||||
label: 'Clone Git repo',
|
||||
testId: 'vault-menu-clone-git',
|
||||
onClick: onCloneVault,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}, [hasGitHub, onConnectGitHub, onOpenLocalFolder])
|
||||
}, [onCloneVault, onOpenLocalFolder])
|
||||
|
||||
return (
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
|
||||
53
src/components/useAiPanelContextSnapshot.ts
Normal file
53
src/components/useAiPanelContextSnapshot.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
buildContextSnapshot,
|
||||
collectLinkedEntries,
|
||||
type NoteListItem,
|
||||
} from '../utils/ai-context'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
|
||||
interface UseAiPanelContextSnapshotArgs {
|
||||
activeEntry?: VaultEntry | null
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
input: string
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
}
|
||||
|
||||
export function useAiPanelContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
input,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
}: UseAiPanelContextSnapshotArgs) {
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const draftReferences = useMemo(
|
||||
() => extractInlineWikilinkReferences(input, entries ?? []),
|
||||
[entries, input],
|
||||
)
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: draftReferences.length > 0 ? draftReferences : undefined,
|
||||
})
|
||||
}, [activeEntry, activeNoteContent, draftReferences, entries, noteList, noteListFilter, openTabs])
|
||||
|
||||
return { linkedEntries, contextPrompt }
|
||||
}
|
||||
78
src/components/useAiPanelController.ts
Normal file
78
src/components/useAiPanelController.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useAiAgent, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type NoteListItem,
|
||||
type NoteReference,
|
||||
} from '../utils/ai-context'
|
||||
import { useAiPanelContextSnapshot } from './useAiPanelContextSnapshot'
|
||||
|
||||
interface UseAiPanelControllerArgs {
|
||||
vaultPath: string
|
||||
activeEntry?: VaultEntry | null
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
export function useAiPanelController({
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: UseAiPanelControllerArgs) {
|
||||
const [input, setInput] = useState('')
|
||||
const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
input,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
})
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
agent.sendMessage(text, references)
|
||||
setInput('')
|
||||
}, [agent, isActive])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
return {
|
||||
agent,
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
}
|
||||
}
|
||||
42
src/components/useAiPanelFocus.ts
Normal file
42
src/components/useAiPanelFocus.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
interface UseAiPanelFocusArgs {
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
panelRef: React.RefObject<HTMLElement | null>
|
||||
isActive: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function useAiPanelFocus({
|
||||
inputRef,
|
||||
panelRef,
|
||||
isActive,
|
||||
onClose,
|
||||
}: UseAiPanelFocusArgs) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
inputRef.current?.focus()
|
||||
}, [inputRef, isActive, panelRef])
|
||||
|
||||
const handleEscape = useCallback((event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
if (!panelRef.current?.contains(document.activeElement)) return
|
||||
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}, [onClose, panelRef])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
}
|
||||
44
src/components/useAiPanelPromptQueue.ts
Normal file
44
src/components/useAiPanelPromptQueue.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import type { QueuedAiPrompt } from '../utils/aiPromptBridge'
|
||||
import { useQueuedAiPrompt } from './useQueuedAiPrompt'
|
||||
|
||||
interface AiAgentBridge {
|
||||
clearConversation: () => void
|
||||
sendMessage: (text: string, references: NoteReference[]) => void
|
||||
}
|
||||
|
||||
interface UseAiPanelPromptQueueArgs {
|
||||
agent: AiAgentBridge
|
||||
input: string
|
||||
isActive: boolean
|
||||
setInput: (value: string) => void
|
||||
}
|
||||
|
||||
export function useAiPanelPromptQueue({
|
||||
agent,
|
||||
input,
|
||||
isActive,
|
||||
setInput,
|
||||
}: UseAiPanelPromptQueueArgs) {
|
||||
const [queuedPrompt, setQueuedPrompt] = useState<QueuedAiPrompt | null>(null)
|
||||
|
||||
const handleQueuedPrompt = useCallback((prompt: QueuedAiPrompt) => {
|
||||
setInput(prompt.text)
|
||||
setQueuedPrompt(prompt)
|
||||
agent.clearConversation()
|
||||
}, [agent, setInput])
|
||||
|
||||
useQueuedAiPrompt(handleQueuedPrompt)
|
||||
|
||||
useEffect(() => {
|
||||
if (!queuedPrompt || isActive) return
|
||||
if (input !== queuedPrompt.text) return
|
||||
|
||||
agent.sendMessage(queuedPrompt.text, queuedPrompt.references)
|
||||
startTransition(() => {
|
||||
setInput('')
|
||||
setQueuedPrompt(null)
|
||||
})
|
||||
}, [agent, input, isActive, queuedPrompt, setInput])
|
||||
}
|
||||
72
src/components/useInlineWikilinkSelection.ts
Normal file
72
src/components/useInlineWikilinkSelection.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
applySelectionIndex,
|
||||
readSelectionIndex,
|
||||
serializeInlineNode,
|
||||
} from './inlineWikilinkDom'
|
||||
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
|
||||
|
||||
interface UseInlineWikilinkSelectionArgs {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function useInlineWikilinkSelection({
|
||||
value,
|
||||
onChange,
|
||||
inputRef,
|
||||
}: UseInlineWikilinkSelectionArgs) {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const [selectionIndex, setSelectionIndex] = useState(value.length)
|
||||
|
||||
const setCombinedRef = useCallback((node: HTMLDivElement | null) => {
|
||||
editorRef.current = node
|
||||
if (inputRef) {
|
||||
inputRef.current = node
|
||||
}
|
||||
}, [inputRef])
|
||||
|
||||
const syncSelectionIndex = useCallback(() => {
|
||||
if (!editorRef.current) return
|
||||
setSelectionIndex(readSelectionIndex(editorRef.current))
|
||||
}, [])
|
||||
|
||||
const focusSelectionAt = useCallback((nextSelectionIndex: number) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
applySelectionIndex(editor, nextSelectionIndex)
|
||||
}, [])
|
||||
|
||||
const commitValueFromEditor = useCallback(() => {
|
||||
if (!editorRef.current) return
|
||||
|
||||
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editorRef.current))
|
||||
const nextSelectionIndex = readSelectionIndex(editorRef.current)
|
||||
|
||||
onChange(nextValue)
|
||||
setSelectionIndex(Math.min(nextSelectionIndex, nextValue.length))
|
||||
}, [onChange])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
if (document.activeElement !== editor) return
|
||||
applySelectionIndex(editor, selectionIndex)
|
||||
}, [selectionIndex, value])
|
||||
|
||||
return {
|
||||
selectionIndex,
|
||||
setSelectionIndex,
|
||||
setCombinedRef,
|
||||
syncSelectionIndex,
|
||||
focusSelectionAt,
|
||||
commitValueFromEditor,
|
||||
}
|
||||
}
|
||||
137
src/components/useInlineWikilinkSuggestionsState.ts
Normal file
137
src/components/useInlineWikilinkSuggestionsState.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
buildInlineWikilinkSuggestions,
|
||||
type InlineWikilinkSuggestion,
|
||||
} from './inlineWikilinkSuggestions'
|
||||
import { replaceActiveWikilinkQuery } from './inlineWikilinkText'
|
||||
|
||||
interface UseInlineWikilinkSuggestionsStateArgs {
|
||||
activeQueryKey: string
|
||||
entries: VaultEntry[]
|
||||
query: string | null
|
||||
value: string
|
||||
selectionIndex: number
|
||||
onChange: (value: string) => void
|
||||
onSelectionIndexChange: (selectionIndex: number) => void
|
||||
focusSelectionAt: (selectionIndex: number) => void
|
||||
}
|
||||
|
||||
function selectedIndexForQuery(
|
||||
queryKey: string,
|
||||
currentState: { queryKey: string; index: number },
|
||||
suggestionCount: number,
|
||||
) {
|
||||
if (currentState.queryKey !== queryKey) return 0
|
||||
return Math.min(currentState.index, Math.max(suggestionCount - 1, 0))
|
||||
}
|
||||
|
||||
function buildInitialSuggestionState(
|
||||
queryKey: string,
|
||||
direction: 1 | -1,
|
||||
suggestionCount: number,
|
||||
) {
|
||||
return {
|
||||
queryKey,
|
||||
index: direction > 0 ? 0 : suggestionCount - 1,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCycledSuggestionState(
|
||||
currentIndex: number,
|
||||
queryKey: string,
|
||||
direction: 1 | -1,
|
||||
suggestionCount: number,
|
||||
) {
|
||||
const nextIndex = direction > 0
|
||||
? (currentIndex + 1) % suggestionCount
|
||||
: (currentIndex <= 0 ? suggestionCount - 1 : currentIndex - 1)
|
||||
|
||||
return { queryKey, index: nextIndex }
|
||||
}
|
||||
|
||||
function replacementForSuggestion(
|
||||
suggestions: InlineWikilinkSuggestion[],
|
||||
index: number,
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
) {
|
||||
const suggestion = suggestions[index]
|
||||
if (!suggestion) return null
|
||||
return replaceActiveWikilinkQuery(value, selectionIndex, suggestion.target)
|
||||
}
|
||||
|
||||
export function useInlineWikilinkSuggestionsState({
|
||||
activeQueryKey,
|
||||
entries,
|
||||
query,
|
||||
value,
|
||||
selectionIndex,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
focusSelectionAt,
|
||||
}: UseInlineWikilinkSuggestionsStateArgs) {
|
||||
const [suggestionState, setSuggestionState] = useState({ queryKey: '', index: 0 })
|
||||
|
||||
const suggestions = useMemo<InlineWikilinkSuggestion[]>(
|
||||
() => (query === null ? [] : buildInlineWikilinkSuggestions(entries, query)),
|
||||
[entries, query],
|
||||
)
|
||||
|
||||
const selectedSuggestionIndex = selectedIndexForQuery(
|
||||
activeQueryKey,
|
||||
suggestionState,
|
||||
suggestions.length,
|
||||
)
|
||||
|
||||
const setSuggestionIndex = useCallback((index: number) => {
|
||||
setSuggestionState({ queryKey: activeQueryKey, index })
|
||||
}, [activeQueryKey])
|
||||
|
||||
const selectSuggestion = useCallback((index: number) => {
|
||||
const replacement = replacementForSuggestion(
|
||||
suggestions,
|
||||
index,
|
||||
value,
|
||||
selectionIndex,
|
||||
)
|
||||
if (!replacement) return
|
||||
|
||||
onChange(replacement.value)
|
||||
onSelectionIndexChange(replacement.nextSelectionIndex)
|
||||
setSuggestionState({ queryKey: '', index: 0 })
|
||||
window.setTimeout(() => focusSelectionAt(replacement.nextSelectionIndex), 0)
|
||||
}, [
|
||||
focusSelectionAt,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
selectionIndex,
|
||||
suggestions,
|
||||
value,
|
||||
])
|
||||
|
||||
const cycleSuggestions = useCallback((direction: 1 | -1) => {
|
||||
if (suggestions.length === 0) return
|
||||
|
||||
setSuggestionState((current) => {
|
||||
if (current.queryKey !== activeQueryKey) {
|
||||
return buildInitialSuggestionState(activeQueryKey, direction, suggestions.length)
|
||||
}
|
||||
|
||||
return buildCycledSuggestionState(
|
||||
current.index,
|
||||
activeQueryKey,
|
||||
direction,
|
||||
suggestions.length,
|
||||
)
|
||||
})
|
||||
}, [activeQueryKey, suggestions.length])
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
setSuggestionIndex,
|
||||
selectSuggestion,
|
||||
cycleSuggestions,
|
||||
}
|
||||
}
|
||||
21
src/components/useQueuedAiPrompt.ts
Normal file
21
src/components/useQueuedAiPrompt.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
AI_PROMPT_QUEUED_EVENT,
|
||||
takeQueuedAiPrompt,
|
||||
type QueuedAiPrompt,
|
||||
} from '../utils/aiPromptBridge'
|
||||
|
||||
export function useQueuedAiPrompt(
|
||||
onPrompt: (prompt: QueuedAiPrompt) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const consumePrompt = () => {
|
||||
const queuedPrompt = takeQueuedAiPrompt()
|
||||
if (queuedPrompt) onPrompt(queuedPrompt)
|
||||
}
|
||||
|
||||
consumePrompt()
|
||||
window.addEventListener(AI_PROMPT_QUEUED_EVENT, consumePrompt)
|
||||
return () => window.removeEventListener(AI_PROMPT_QUEUED_EVENT, consumePrompt)
|
||||
}, [onPrompt])
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,8 +8,6 @@ function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockA
|
||||
}
|
||||
|
||||
const EMPTY_SETTINGS: Settings = {
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
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<string, (args: any) => any> = {
|
||||
list_vault: () => MOCK_ENTRIES,
|
||||
@@ -298,8 +292,6 @@ export const mockHandlers: Record<string, (args: any) => 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<string, (args: any) => 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<string, (args: any) => 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 }
|
||||
|
||||
32
src/types.ts
32
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
|
||||
|
||||
34
src/utils/aiPromptBridge.ts
Normal file
34
src/utils/aiPromptBridge.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { NoteReference } from './ai-context'
|
||||
|
||||
export const OPEN_AI_CHAT_EVENT = 'tolaria:open-ai-chat'
|
||||
export const AI_PROMPT_QUEUED_EVENT = 'tolaria:ai-prompt-queued'
|
||||
|
||||
export interface QueuedAiPrompt {
|
||||
id: number
|
||||
text: string
|
||||
references: NoteReference[]
|
||||
}
|
||||
|
||||
let nextQueuedPromptId = 1
|
||||
let pendingPrompt: QueuedAiPrompt | null = null
|
||||
|
||||
export function queueAiPrompt(text: string, references: NoteReference[]): QueuedAiPrompt {
|
||||
const queuedPrompt = {
|
||||
id: nextQueuedPromptId++,
|
||||
text,
|
||||
references,
|
||||
}
|
||||
pendingPrompt = queuedPrompt
|
||||
window.dispatchEvent(new Event(AI_PROMPT_QUEUED_EVENT))
|
||||
return queuedPrompt
|
||||
}
|
||||
|
||||
export function takeQueuedAiPrompt(): QueuedAiPrompt | null {
|
||||
const queuedPrompt = pendingPrompt
|
||||
pendingPrompt = null
|
||||
return queuedPrompt
|
||||
}
|
||||
|
||||
export function requestOpenAiChat() {
|
||||
window.dispatchEvent(new Event(OPEN_AI_CHAT_EVENT))
|
||||
}
|
||||
@@ -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<string, unknown>, reload = false) => {
|
||||
const resolvedPath = String(commandArgs?.path ?? resolvedVaultPath)
|
||||
return readJson(
|
||||
`/api/vault/list?path=${encodeURIComponent(resolvedPath)}&reload=${reload ? '1' : '0'}`,
|
||||
)
|
||||
}
|
||||
|
||||
const renameNoteRequest = (payload: Record<string, unknown>) =>
|
||||
readJson('/api/vault/rename', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const commandHandlers: Record<string, (commandArgs?: Record<string, unknown>) => Promise<unknown> | 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<string, unknown>) => {
|
||||
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__', {
|
||||
|
||||
@@ -21,7 +21,7 @@ test.describe('AI chat empty body fix — no regression', () => {
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Send a message
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
const input = page.getByTestId('agent-input')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('What does this note contain?')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
Reference in New Issue
Block a user