Compare commits

...

13 Commits

Author SHA1 Message Date
lucaronin
fbbd53d204 test: restore smoke flows after Claude onboarding 2026-04-12 20:10:01 +02:00
lucaronin
fd9de04275 feat: add first-launch Claude Code onboarding 2026-04-12 19:56:10 +02:00
lucaronin
a13e36a504 feat: support local-only git commits without remotes 2026-04-12 19:38:25 +02:00
lucaronin
73a63085c7 fix: tighten vault switcher typing 2026-04-12 19:19:52 +02:00
lucaronin
739e1b3c20 feat: handle offline onboarding gracefully 2026-04-12 19:17:49 +02:00
lucaronin
e2745d96eb refactor: remove github auth integration 2026-04-12 17:08:07 +02:00
lucaronin
a2c84d3ce8 test: align AI smoke selector with inline input 2026-04-12 13:20:26 +02:00
lucaronin
afbea1367c fix: narrow inline wikilink selection lookup 2026-04-12 13:15:51 +02:00
lucaronin
7933fe1bfb feat: add inline AI prompts to command palette 2026-04-12 13:14:18 +02:00
lucaronin
d16aa29d18 docs: test notes must be deleted from disk after QA; update QA script paths to tolaria-qa 2026-04-12 12:31:35 +02:00
lucaronin
6188395141 fix: align desktop test bridge types 2026-04-12 12:29:13 +02:00
lucaronin
1bf5a7d6e4 fix: preserve resized tables across raw mode toggles 2026-04-12 11:45:59 +02:00
lucaronin
27e371c7fe docs: restore README content lost during Laputa→Tolaria rename 2026-04-12 10:40:50 +02:00
99 changed files with 5136 additions and 3923 deletions

View File

@@ -41,8 +41,8 @@ BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
@@ -107,7 +107,10 @@ After any Tauri command, new component/hook, data model change, or new integrati
### User vault (`~/Laputa/`)
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
- **Never commit or push** any test notes to the remote vault
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
### UI design
@@ -145,9 +148,9 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
### QA scripts
```bash
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
```
### Diagrams

View File

@@ -3,3 +3,91 @@
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.
## Documentation
- 📐 [ARCHITECTURE.md](docs/ARCHITECTURE.md) — System design, tech stack, data flow
- 🧩 [ABSTRACTIONS.md](docs/ABSTRACTIONS.md) — Core abstractions and models
- 🚀 [GETTING-STARTED.md](docs/GETTING-STARTED.md) — How to navigate the codebase
- 🎨 [THEMING.md](docs/THEMING.md) — Theme system and customization
## Quick Start
### Prerequisites
- Node.js 20+
- pnpm 8+
- Rust (latest stable)
- macOS (for development)
### Setup
```bash
# Install dependencies
pnpm install
# Run dev server
pnpm dev
# Open in browser (mock mode)
open http://localhost:5173
# Or run in Tauri
pnpm tauri dev
```
### Testing
```bash
# Frontend tests
pnpm test
# Backend tests
cargo test
# Coverage
pnpm test:coverage
# E2E tests
pnpm test:e2e
```
### Code Quality
```bash
# Lint
pnpm lint
# Rust checks
cargo clippy
cargo fmt --check
# CodeScene (via Claude Code)
claude 'Check code health with CodeScene MCP'
```
## Development Workflow
See [AGENTS.md](AGENTS.md) for coding guidelines and workflow. [CLAUDE.md](CLAUDE.md) remains as a compatibility shim for Claude Code.
**Key principles:**
- Small, atomic commits
- Test as you go
- Visual verification mandatory
- Documentation updated with code changes
## CI/CD
GitHub Actions runs on every push to `main`:
- ✅ Tests (frontend + Rust)
- 📊 Coverage (70% threshold)
- 🎨 Lint & format
- ⚠️ Documentation check
See [.github/SETUP.md](.github/SETUP.md) for CI/CD configuration.
## Git Hooks
Husky installs the git hooks from `.husky/` during `pnpm install`. The repo enforces `main`-only commits and pushes; see [.github/HOOKS.md](.github/HOOKS.md) for details.
## License
Private repository — not licensed for public use.

View File

@@ -336,6 +336,13 @@ interface ModifiedFile {
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
}
interface GitRemoteStatus {
branch: string
ahead: number
behind: number
hasRemote: boolean
}
interface PulseCommit {
hash: string
shortHash: string
@@ -372,12 +379,18 @@ interface PulseCommit {
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
- Converts `hasRemote: false` into a local-only commit path
- Keeps the normal push path unchanged for vaults that do have a remote
### Frontend Integration
- **Modified file badges**: Orange dots in sidebar
- **Diff view**: Toggle in breadcrumb bar → shows unified diff
- **Git history**: Shown in Inspector panel for active note
- **Commit dialog**: Triggered from sidebar or Cmd+K
- **No remote indicator**: Neutral chip in the bottom bar when `GitRemoteStatus.hasRemote === false`
- **Pulse view**: Activity feed when Pulse filter is selected
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button
@@ -514,13 +527,18 @@ 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
`useClaudeCodeOnboarding(enabled)` adds a separate first-launch agent step:
- Reads a local dismissal flag for the Claude Code prompt
- Only shows after vault onboarding has already resolved to a ready state
- Persists dismissal locally once the user continues
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
### Remote Git Operations
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 +546,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
}
```

View File

@@ -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,25 @@ 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.
Once a vault is ready, `useClaudeCodeOnboarding` can show a one-time `ClaudeCodeOnboardingPrompt`. That prompt reuses `useClaudeCodeStatus` so first launch surfaces whether the `claude` CLI is installed, offers the install link when it is missing, and stores local dismissal so the prompt does not repeat on every launch.
### GitHub OAuth Integration
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.
Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed vaults.
### Remote Clone & Auth Model
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
@@ -519,8 +524,13 @@ flowchart TD
PC -->|Fast-forward| RV["reload vault"]
PC -->|Up to date| DONE["idle"]
MAN["Manual commit\n(CommitDialog)"] --> GC["invoke('git_commit', message)"]
GC --> GP["invoke('git_push')"]
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
RS --> RCHK["invoke('git_remote_status')"]
RCHK --> RMODE{Remote configured?}
RMODE -->|No| GC["invoke('git_commit', message)"]
GC --> LOCAL["Local commit only\nNo remote chip + local toast"]
RMODE -->|Yes| GC2["invoke('git_commit', message)"]
GC2 --> GP["invoke('git_push')"]
GP --> PR{Push result?}
PR -->|ok| RM["Reload modified files"]
PR -->|rejected| DIV["syncStatus = pull_required"]
@@ -533,6 +543,8 @@ flowchart TD
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
```
`useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
#### Sync States
| State | Indicator | Color | Trigger |
@@ -565,8 +577,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 +587,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 +631,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
@@ -704,8 +705,9 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useTheme` | Editor theme CSS vars | Editor typography theme |
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `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 +870,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

View File

@@ -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

View 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.

View File

@@ -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 |

View File

@@ -1,18 +1,32 @@
import { defineConfig } from '@playwright/test'
const baseURL = process.env.BASE_URL || 'http://localhost:5201'
const claudeCodeOnboardingStorageState = {
cookies: [],
origins: [
{
origin: baseURL,
localStorage: [
{ name: 'tolaria:claude-code-onboarding-dismissed', value: '1' },
],
},
],
}
export default defineConfig({
testDir: './tests/smoke',
timeout: 20_000,
retries: 2,
workers: 1,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5201',
baseURL,
headless: true,
storageState: claudeCodeOnboardingStorageState,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`,
url: process.env.BASE_URL || 'http://localhost:5201',
url: baseURL,
reuseExistingServer: true,
},
})

View File

@@ -3,6 +3,17 @@ import { defineConfig } from '@playwright/test'
const baseURL = process.env.BASE_URL || 'http://127.0.0.1:41741'
const port = new URL(baseURL).port || '41741'
const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_SERVER === '1'
const claudeCodeOnboardingStorageState = {
cookies: [],
origins: [
{
origin: baseURL,
localStorage: [
{ name: 'tolaria:claude-code-onboarding-dismissed', value: '1' },
],
},
],
}
export default defineConfig({
testDir: './tests',
@@ -13,6 +24,7 @@ export default defineConfig({
use: {
baseURL,
headless: true,
storageState: claudeCodeOnboardingStorageState,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {

178
src-tauri/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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())
}

View File

@@ -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())
}

View File

@@ -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
View 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"));
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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()));
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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,

View File

@@ -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");
}
}

View File

@@ -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)
}

View File

@@ -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,
@@ -123,6 +123,8 @@ vi.mock('@blocknote/mantine/style.css', () => ({}))
import App from './App'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -133,6 +135,7 @@ describe('App', () => {
localStorage.removeItem('laputa-view-mode')
localStorage.removeItem('tolaria_welcome_dismissed')
localStorage.removeItem('laputa_welcome_dismissed')
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
})
it('renders the four-panel layout', async () => {

View File

@@ -14,17 +14,20 @@ 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 { ClaudeCodeOnboardingPrompt } from './components/ClaudeCodeOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useClaudeCodeOnboarding } from './hooks/useClaudeCodeOnboarding'
import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
@@ -39,6 +42,7 @@ import { useZoom } from './hooks/useZoom'
import { useVaultConfig } from './hooks/useVaultConfig'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useNetworkStatus } from './hooks/useNetworkStatus'
import { useAppNavigation } from './hooks/useAppNavigation'
import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
@@ -64,6 +68,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,9 +102,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), [])
const networkStatus = useNetworkStatus()
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
@@ -110,6 +126,8 @@ function App() {
})
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
const claudeCodeOnboarding = useClaudeCodeOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
@@ -160,7 +178,6 @@ function App() {
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
const autoSync = useAutoSync({
vaultPath: resolvedPath,
@@ -172,6 +189,7 @@ function App() {
},
onToast: (msg) => setToastMessage(msg),
})
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
@@ -405,7 +423,14 @@ function App() {
}
}, [vault, notes, setToastMessage])
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
const commitFlow = useCommitFlow({
savePending: appSave.savePending,
loadModifiedFiles: vault.loadModifiedFiles,
resolveRemoteStatus: gitRemoteStatus.refreshRemoteStatus,
setToastMessage,
onPushRejected: autoSync.handlePushRejected,
vaultPath: resolvedPath,
})
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
const entryActions = useEntryActions({
@@ -610,7 +635,7 @@ function App() {
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
return <WelcomeView onboarding={onboarding} />
return <WelcomeView onboarding={onboarding} isOffline={networkStatus.isOffline} />
}
// Show loading spinner while checking vault (skip for note windows)
@@ -618,6 +643,15 @@ function App() {
return <LoadingView />
}
if (!noteWindowParams && onboarding.state.status === 'ready' && claudeCodeOnboarding.showPrompt) {
return (
<ClaudeCodeOnboardingView
status={claudeCodeStatus}
onContinue={claudeCodeOnboarding.dismissPrompt}
/>
)
}
// Show git-required modal when vault has no git repo (skip for note windows)
if (!noteWindowParams && gitRepoState === 'required') {
return (
@@ -726,14 +760,27 @@ 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} onCloneGettingStarted={vaultSwitcher.restoreGettingStarted} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} 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} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
commitMode={commitFlow.commitMode}
suggestedMessage={suggestedCommitMessage}
onCommit={commitFlow.handleCommitPush}
onClose={commitFlow.closeCommitDialog}
/>
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
@@ -747,14 +794,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}
@@ -772,7 +812,7 @@ function App() {
type OnboardingState = ReturnType<typeof useOnboarding>
/** Welcome screen view - extracted from main App component */
function WelcomeView({ onboarding }: { onboarding: OnboardingState }) {
function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; isOffline: boolean }) {
const state = onboarding.state as { status: 'welcome' | 'vault-missing'; defaultPath: string; vaultPath?: string }
return (
<div className="app-shell">
@@ -784,6 +824,7 @@ function WelcomeView({ onboarding }: { onboarding: OnboardingState }) {
onRetryCreateVault={onboarding.retryCreateVault}
onCreateNewVault={onboarding.handleCreateNewVault}
onOpenFolder={onboarding.handleOpenFolder}
isOffline={isOffline}
creatingAction={onboarding.creatingAction}
error={onboarding.error}
canRetryTemplate={onboarding.canRetryTemplate}
@@ -792,6 +833,20 @@ function WelcomeView({ onboarding }: { onboarding: OnboardingState }) {
)
}
function ClaudeCodeOnboardingView({
status,
onContinue,
}: {
status: ReturnType<typeof useClaudeCodeStatus>['status']
onContinue: () => void
}) {
return (
<div className="app-shell">
<ClaudeCodeOnboardingPrompt status={status} onContinue={onContinue} />
</div>
)
}
/** Loading spinner view - extracted from main App component */
function LoadingView() {
return (

View File

@@ -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()
})
})

View File

@@ -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>
)
}

View File

@@ -0,0 +1,56 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ClaudeCodeOnboardingPrompt } from './ClaudeCodeOnboardingPrompt'
const openExternalUrl = vi.fn()
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
}))
describe('ClaudeCodeOnboardingPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the detected state with a continue action', () => {
render(<ClaudeCodeOnboardingPrompt status="installed" onContinue={vi.fn()} />)
expect(screen.getByText('Claude Code detected')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-continue')).toHaveTextContent('Continue')
expect(screen.queryByTestId('claude-onboarding-install')).not.toBeInTheDocument()
})
it('shows the install path when Claude Code is missing', () => {
render(<ClaudeCodeOnboardingPrompt status="missing" onContinue={vi.fn()} />)
expect(screen.getByText('Claude Code not detected')).toBeInTheDocument()
expect(screen.getByText('Install Claude Code to enable AI-powered note management.')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-install')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-continue')).toHaveTextContent('Continue without it')
})
it('opens the Claude Code install page', () => {
render(<ClaudeCodeOnboardingPrompt status="missing" onContinue={vi.fn()} />)
fireEvent.click(screen.getByTestId('claude-onboarding-install'))
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
})
it('calls onContinue from the detected state', () => {
const onContinue = vi.fn()
render(<ClaudeCodeOnboardingPrompt status="installed" onContinue={onContinue} />)
fireEvent.click(screen.getByTestId('claude-onboarding-continue'))
expect(onContinue).toHaveBeenCalledOnce()
})
it('disables continue while detection is still running', () => {
render(<ClaudeCodeOnboardingPrompt status="checking" onContinue={vi.fn()} />)
expect(screen.getByText('Checking for Claude Code')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-continue')).toBeDisabled()
})
})

View File

@@ -0,0 +1,101 @@
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import { openExternalUrl } from '../utils/url'
import { Button } from './ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
const CLAUDE_CODE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
interface ClaudeCodeOnboardingPromptProps {
status: ClaudeCodeStatus
onContinue: () => void
}
function getPromptCopy(status: ClaudeCodeStatus) {
if (status === 'installed') {
return {
accentClassName: 'bg-emerald-100 text-emerald-700',
description: "Tolaria's AI features are ready to use.",
icon: <CheckCircle2 className="size-7" />,
title: 'Claude Code detected',
}
}
if (status === 'missing') {
return {
accentClassName: 'bg-amber-100 text-amber-700',
description: 'Tolaria works best with an AI coding agent installed.',
icon: <Bot className="size-7" />,
title: 'Claude Code not detected',
}
}
return {
accentClassName: 'bg-slate-100 text-slate-600',
description: 'Checking whether Claude Code is available on this machine.',
icon: <Loader2 className="size-7 animate-spin" />,
title: 'Checking for Claude Code',
}
}
export function ClaudeCodeOnboardingPrompt({
status,
onContinue,
}: ClaudeCodeOnboardingPromptProps) {
const copy = getPromptCopy(status)
return (
<div
className="flex h-full w-full items-center justify-center bg-sidebar px-6 py-10"
data-testid="claude-onboarding-screen"
>
<Card className="w-full max-w-2xl border-border bg-background shadow-sm">
<CardHeader className="items-center gap-5 text-center">
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
{copy.icon}
</div>
<div className="space-y-2">
<CardTitle className="text-3xl tracking-tight">
{copy.title}
</CardTitle>
<p className="text-sm leading-6 text-muted-foreground" data-testid="claude-onboarding-description">
{status === 'installed' && '✅ '}
{status === 'missing' && '🤖 '}
{copy.description}
</p>
</div>
</CardHeader>
<CardContent className="space-y-3 text-center">
{status === 'missing' && (
<p className="text-sm leading-6 text-muted-foreground">
Install Claude Code to enable AI-powered note management.
</p>
)}
</CardContent>
<CardFooter className="justify-center gap-3">
{status === 'missing' && (
<Button
type="button"
variant="outline"
onClick={() => void openExternalUrl(CLAUDE_CODE_INSTALL_URL)}
data-testid="claude-onboarding-install"
>
Install Claude Code
<ArrowUpRight className="size-4" />
</Button>
)}
<Button
type="button"
onClick={onContinue}
disabled={status === 'checking'}
data-testid="claude-onboarding-continue"
>
{status === 'missing' ? 'Continue without it' : status === 'installed' ? 'Continue' : 'Checking…'}
</Button>
</CardFooter>
</Card>
</div>
)
}

View 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')
})
})
})

View 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>
)
}

View File

@@ -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' }),

View File

@@ -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>
)

View 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>
)}
/>
)
}

View File

@@ -10,9 +10,8 @@ describe('CommitDialog', () => {
vi.clearAllMocks()
})
function getCommitButton() {
// "Commit & Push" appears in both dialog title and button — use role to disambiguate
return screen.getByRole('button', { name: 'Commit & Push' })
function getActionButton(name = 'Commit & Push') {
return screen.getByRole('button', { name })
}
it('shows file count badge', () => {
@@ -27,21 +26,21 @@ describe('CommitDialog', () => {
it('disables Commit button when message is empty', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
expect(getCommitButton()).toBeDisabled()
expect(getActionButton()).toBeDisabled()
})
it('enables Commit button when message is typed', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: 'fix: bug fix' } })
expect(getCommitButton()).not.toBeDisabled()
expect(getActionButton()).not.toBeDisabled()
})
it('calls onCommit with trimmed message on button click', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: ' fix: bug fix ' } })
fireEvent.click(getCommitButton())
fireEvent.click(getActionButton())
expect(onCommit).toHaveBeenCalledWith('fix: bug fix')
})
@@ -70,7 +69,7 @@ describe('CommitDialog', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: ' ' } })
fireEvent.click(getCommitButton())
fireEvent.click(getActionButton())
expect(onCommit).not.toHaveBeenCalled()
})
@@ -87,7 +86,7 @@ describe('CommitDialog', () => {
it('enables Commit button when suggestedMessage is provided', () => {
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
expect(getCommitButton()).not.toBeDisabled()
expect(getActionButton()).not.toBeDisabled()
})
it('submits suggestedMessage on Cmd+Enter without user edits', () => {
@@ -101,7 +100,16 @@ describe('CommitDialog', () => {
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: 'fix: corrected typo in alpha' } })
fireEvent.click(getCommitButton())
fireEvent.click(getActionButton())
expect(onCommit).toHaveBeenCalledWith('fix: corrected typo in alpha')
})
it('switches to local-only copy when commitMode is local', () => {
render(<CommitDialog open={true} modifiedCount={2} commitMode="local" onCommit={onCommit} onClose={onClose} />)
expect(screen.getByRole('heading', { name: 'Commit' })).toBeInTheDocument()
expect(screen.getByText('This vault has no git remote configured. Tolaria will create a local commit only.')).toBeInTheDocument()
expect(screen.getByText('Cmd+Enter to commit locally')).toBeInTheDocument()
expect(getActionButton('Commit')).toBeDisabled()
})
})

View File

@@ -2,18 +2,66 @@ import { useState, useEffect, useRef } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import type { CommitMode } from '../hooks/useCommitFlow'
type CommitDialogCopy = {
title: string
description: string
actionLabel: string
shortcutHint: string
}
function getDialogCopy(commitMode: CommitMode): CommitDialogCopy {
if (commitMode === 'local') {
return {
title: 'Commit',
description: 'This vault has no git remote configured. Tolaria will create a local commit only.',
actionLabel: 'Commit',
shortcutHint: 'Cmd+Enter to commit locally',
}
}
return {
title: 'Commit & Push',
description: 'Review changed files and enter a commit message before committing and pushing.',
actionLabel: 'Commit & Push',
shortcutHint: 'Cmd+Enter to commit',
}
}
function changedFilesLabel(modifiedCount: number): string {
return `${modifiedCount} file${modifiedCount !== 1 ? 's' : ''} changed`
}
function isSubmitShortcut(event: React.KeyboardEvent): boolean {
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
}
function isCloseShortcut(event: React.KeyboardEvent): boolean {
return event.key === 'Escape'
}
interface CommitDialogProps {
open: boolean
modifiedCount: number
commitMode?: CommitMode
suggestedMessage?: string
onCommit: (message: string) => void
onClose: () => void
}
export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit, onClose }: CommitDialogProps) {
export function CommitDialog({
open,
modifiedCount,
commitMode = 'push',
suggestedMessage,
onCommit,
onClose,
}: CommitDialogProps) {
const [message, setMessage] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const copy = getDialogCopy(commitMode)
useEffect(() => {
if (open) {
@@ -29,10 +77,10 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if (isSubmitShortcut(e)) {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
} else if (isCloseShortcut(e)) {
onClose()
}
}
@@ -42,18 +90,16 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Commit & Push</DialogTitle>
<DialogTitle>{copy.title}</DialogTitle>
<Badge variant="secondary" className="text-xs">
{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed
{changedFilesLabel(modifiedCount)}
</Badge>
</div>
<DialogDescription className="sr-only">
Review changed files and enter a commit message before committing and pushing.
</DialogDescription>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
<textarea
<Textarea
ref={inputRef}
className="w-full resize-y rounded-lg border border-input bg-[var(--bg-input)] px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-ring"
className="min-h-[84px] resize-y bg-[var(--bg-input)] py-2.5 text-[13px]"
placeholder="Commit message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
@@ -61,13 +107,13 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
rows={3}
/>
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">Cmd+Enter to commit</span>
<span className="text-[11px] text-muted-foreground">{copy.shortcutHint}</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!message.trim()}>
Commit & Push
{copy.actionLabel}
</Button>
</div>
</DialogFooter>

View File

@@ -52,6 +52,7 @@ vi.mock('@blocknote/mantine', () => ({
vi.mock('@blocknote/mantine/style.css', () => ({}))
import { Editor } from './Editor'
import { applyPendingRawExitContent } from './editorRawModeSync'
import type { VaultEntry } from '../types'
const mockEntry: VaultEntry = {
@@ -98,6 +99,13 @@ This is a test note with some words to count.
`
const mockTab = { entry: mockEntry, content: mockContent }
const otherEntry: VaultEntry = {
...mockEntry,
path: '/vault/other.md',
filename: 'other.md',
title: 'Other Note',
}
const otherTab = { entry: otherEntry, content: '# Other\n' }
const defaultProps = {
tabs: [] as { entry: VaultEntry; content: string }[],
@@ -270,6 +278,27 @@ describe('Editor', () => {
})
})
describe('applyPendingRawExitContent', () => {
it('overrides only the matching tab when raw content is newer than tab state', () => {
const pending = {
path: mockEntry.path,
content: '---\ntype: Note\nstatus: Active\n---\n| Head 1 | Head 2 | Head 3 |\n| --- | --- | --- |\n| A | B | C |\n',
}
const result = applyPendingRawExitContent([mockTab, otherTab], pending)
expect(result[0]).toEqual({ ...mockTab, content: pending.content })
expect(result[1]).toBe(otherTab)
})
it('returns the original tabs array when the pending raw content is already synced', () => {
const tabs = [mockTab, otherTab]
const pending = { path: mockEntry.path, content: mockContent }
expect(applyPendingRawExitContent(tabs, pending)).toBe(tabs)
})
})
describe('click empty editor space', () => {
it('focuses editor at end of last block when clicking empty space below content', () => {
mockEditor.focus.mockClear()

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback, memo } from 'react'
import { useRef, useEffect, useCallback, useState, memo } from 'react'
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
@@ -13,6 +13,16 @@ import { useEditorFocus } from '../hooks/useEditorFocus'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { schema } from './editorSchema'
import { clearTableResizeState } from './tableResizeState'
import {
type PendingRawExitContent,
applyPendingRawExitContent,
buildPendingRawExitContent,
rememberPendingRawExitContent,
resolvePendingRawExitContent,
resolveRawModeContent,
syncActiveTabIntoRawBuffer,
} from './editorRawModeSync'
import './Editor.css'
import './EditorTheme.css'
@@ -129,23 +139,45 @@ interface EditorSetupParams {
}
function useRawModeWithFlush(
editor: ReturnType<typeof useCreateBlockNote>,
activeTabPath: string | null,
activeTabContent: string | null,
onContentChange?: (path: string, content: string) => void,
) {
const rawLatestContentRef = useRef<string | null>(null)
const [pendingRawExitContent, setPendingRawExitContent] = useState<PendingRawExitContent | null>(null)
const [rawModeContentOverride, setRawModeContentOverride] = useState<PendingRawExitContent | null>(null)
const handleFlushPending = useCallback(async () => {
const syncedContent = syncActiveTabIntoRawBuffer({
editor,
activeTabPath,
activeTabContent,
rawLatestContentRef,
onContentChange,
})
setRawModeContentOverride(buildPendingRawExitContent(activeTabPath, syncedContent))
clearTableResizeState(editor)
return true
}, [activeTabContent, activeTabPath, editor, onContentChange])
const handleBeforeRawEnd = useCallback(() => {
if (rawLatestContentRef.current != null && activeTabPath) {
onContentChange?.(activeTabPath, rawLatestContentRef.current)
}
setPendingRawExitContent(rememberPendingRawExitContent({
activeTabPath,
rawLatestContentRef,
onContentChange,
}))
setRawModeContentOverride(null)
rawLatestContentRef.current = null
}, [activeTabPath, onContentChange])
const { rawMode, handleToggleRaw } = useRawMode({
activeTabPath, onBeforeRawEnd: handleBeforeRawEnd,
activeTabPath,
onFlushPending: handleFlushPending,
onBeforeRawEnd: handleBeforeRawEnd,
})
return { rawMode, handleToggleRaw, rawLatestContentRef }
return { rawMode, handleToggleRaw, rawLatestContentRef, pendingRawExitContent, setPendingRawExitContent, rawModeContentOverride }
}
function useEditorSetup({
@@ -160,15 +192,33 @@ function useEditorSetup({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
})
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
const { rawMode, handleToggleRaw, rawLatestContentRef } = useRawModeWithFlush(
activeTabPath, onContentChange,
const {
rawMode,
handleToggleRaw,
rawLatestContentRef,
pendingRawExitContent,
setPendingRawExitContent,
rawModeContentOverride,
} = useRawModeWithFlush(
editor,
activeTabPath,
activeTab?.content ?? null,
onContentChange,
)
const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent)
const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride })
useEffect(() => {
setPendingRawExitContent((current) => resolvePendingRawExitContent({
activeTabPath,
tabs,
pendingRawExitContent: current,
}))
}, [activeTabPath, setPendingRawExitContent, tabs])
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
tabs, activeTabPath, editor, onContentChange, rawMode,
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode,
})
useEditorFocus(editor, editorMountedRef)
@@ -185,7 +235,7 @@ function useEditorSetup({
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
return {
editor, activeTab, rawLatestContentRef,
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
@@ -209,7 +259,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
} = props
const {
editor, activeTab, rawLatestContentRef,
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
@@ -253,6 +303,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}

View File

@@ -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>
)
}

View File

@@ -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()
})
})

View File

@@ -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>
)
}

View 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>
}

View 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}
</>
)
}

View File

@@ -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', () => {

View File

@@ -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 }}>

View File

@@ -13,6 +13,72 @@ import type { VaultEntry } from '../types'
import { _wikilinkEntriesRef } from './editorSchema'
import { useEditorLinkActivation } from './useEditorLinkActivation'
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
| --- | --- | --- |
| A | B | C |
| D | E | F |
`
type TestTableBlock = {
type?: string
content?: { type?: string; columnWidths?: Array<number | null> }
}
function applySeededColumnWidths(
parsedBlocks: Array<TestTableBlock>,
columnWidths?: Array<number | null>,
) {
const tableBlock = parsedBlocks[0]
const tableContent = tableBlock?.content
if (
!columnWidths ||
tableBlock?.type !== 'table' ||
tableContent?.type !== 'tableContent'
) {
return
}
tableContent.columnWidths = [...columnWidths]
}
async function seedEditorWithTestTable(
editor: ReturnType<typeof useCreateBlockNote>,
columnWidths?: Array<number | null>,
) {
const parsedBlocks = await Promise.resolve(
editor.tryParseMarkdownToBlocks(TEST_TABLE_MARKDOWN),
) as Array<TestTableBlock>
applySeededColumnWidths(parsedBlocks, columnWidths)
const tableHtml = editor.blocksToHTMLLossy([
...parsedBlocks,
{ type: 'paragraph', content: [], children: [] },
] as typeof editor.document)
editor._tiptapEditor.commands.setContent(tableHtml)
editor.focus()
}
function useSeedBlockNoteTableBridge(editor: ReturnType<typeof useCreateBlockNote>) {
useEffect(() => {
const seedBlockNoteTable = (columnWidths?: Array<number | null>) => (
seedEditorWithTestTable(editor, columnWidths)
)
window.__laputaTest = {
...window.__laputaTest,
seedBlockNoteTable,
}
return () => {
if (window.__laputaTest?.seedBlockNoteTable === seedBlockNoteTable) {
delete window.__laputaTest.seedBlockNoteTable
}
}
}, [editor])
}
/** Insert an image block after the current cursor position. */
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
const editorRef = useRef(editor)
@@ -54,6 +120,8 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
_wikilinkEntriesRef.current = entries
}, [entries])
useSeedBlockNoteTableBridge(editor)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(

View File

@@ -187,12 +187,44 @@ 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 the Getting Started clone action in the vault menu when provided', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCloneGettingStarted={vi.fn()}
/>
)
fireEvent.click(screen.getByTitle('Switch vault'))
expect(screen.getByText('Clone Getting Started Vault')).toBeInTheDocument()
})
it('calls onCloneGettingStarted when clicking the vault menu action', () => {
const onCloneGettingStarted = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCloneGettingStarted={onCloneGettingStarted}
/>
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByText('Clone Getting Started Vault'))
expect(onCloneGettingStarted).toHaveBeenCalledOnce()
})
it('shows Changes badge with count when modifiedCount is > 0', () => {
@@ -300,6 +332,26 @@ describe('StatusBar', () => {
expect(screen.getByText('Pull required')).toBeInTheDocument()
})
it('shows an offline chip when offline', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isOffline={true} />
)
expect(screen.getByTestId('status-offline')).toHaveTextContent('Offline')
})
it('shows a no-remote chip when the active git vault has no remote', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
/>
)
expect(screen.getByTestId('status-no-remote')).toHaveTextContent('No remote')
})
it('calls onPullAndPush when clicking Pull required badge', () => {
const onPullAndPush = vi.fn()
render(
@@ -355,6 +407,21 @@ describe('StatusBar', () => {
expect(onCommitPush).toHaveBeenCalledOnce()
})
it('uses a local-only tooltip for the commit button when no remote is configured', () => {
render(
<StatusBar
noteCount={100}
modifiedCount={5}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCommitPush={vi.fn()}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
/>
)
expect(screen.getByTestId('status-commit-push')).toHaveAttribute('title', 'Commit locally (no remote configured)')
})
it('shows Commit button even when no modified files', () => {
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={vi.fn()} />)
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()

View File

@@ -18,12 +18,13 @@ interface StatusBarProps {
onSwitchVault: (path: string) => void
onOpenSettings?: () => void
onOpenLocalFolder?: () => void
onConnectGitHub?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onClickPending?: () => void
onClickPulse?: () => void
onCommitPush?: () => void
isOffline?: boolean
isGitVault?: boolean
hasGitHub?: boolean
syncStatus?: SyncStatus
lastSyncTime?: number | null
conflictCount?: number
@@ -52,12 +53,13 @@ export function StatusBar({
onSwitchVault,
onOpenSettings,
onOpenLocalFolder,
onConnectGitHub,
onCloneVault,
onCloneGettingStarted,
onClickPending,
onClickPulse,
onCommitPush,
isOffline = false,
isGitVault = false,
hasGitHub,
syncStatus = 'idle',
lastSyncTime = null,
conflictCount = 0,
@@ -107,11 +109,12 @@ export function StatusBar({
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onConnectGitHub={onConnectGitHub}
hasGitHub={hasGitHub}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}
onClickPulse={onClickPulse}
onCommitPush={onCommitPush}
isOffline={isOffline}
isGitVault={isGitVault}
syncStatus={syncStatus}
lastSyncTime={lastSyncTime}

View File

@@ -9,6 +9,7 @@ const defaultProps = {
onRetryCreateVault: vi.fn(),
onCreateNewVault: vi.fn(),
onOpenFolder: vi.fn(),
isOffline: false,
creatingAction: null as 'template' | 'empty' | null,
error: null,
canRetryTemplate: false,
@@ -34,6 +35,12 @@ describe('WelcomeScreen', () => {
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()
})
it('shows offline guidance and disables the template option when offline', () => {
render(<WelcomeScreen {...defaultProps} isOffline={true} />)
expect(screen.getByTestId('welcome-create-vault')).toBeDisabled()
expect(screen.getByText(/Requires internet — clone later/)).toBeInTheDocument()
})
it('calls onCreateNewVault when create new button is clicked', () => {
const onCreateNewVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateNewVault={onCreateNewVault} />)

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
interface WelcomeScreenProps {
@@ -9,11 +10,21 @@ interface WelcomeScreenProps {
onRetryCreateVault: () => void
onCreateNewVault: () => void
onOpenFolder: () => void
isOffline: boolean
creatingAction: 'template' | 'empty' | null
error: string | null
canRetryTemplate: boolean
}
interface WelcomeScreenPresentation {
heroBackground: string
heroIcon: ReactNode
openFolderLabel: string
subtitle: string
templateDescription: string
title: string
}
const CONTAINER_STYLE: React.CSSProperties = {
width: '100%',
height: '100%',
@@ -189,6 +200,36 @@ function OptionButton({
)
}
function getWelcomeScreenPresentation(
mode: WelcomeScreenProps['mode'],
defaultVaultPath: string,
isOffline: boolean,
): WelcomeScreenPresentation {
if (mode === 'welcome') {
return {
heroBackground: 'var(--accent-blue-light, #EBF4FF)',
heroIcon: <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>&#10022;</span>,
openFolderLabel: 'Open existing vault',
subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.',
templateDescription: isOffline
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
: `Download the starter vault template — suggested path: ${defaultVaultPath}`,
title: 'Welcome to Tolaria',
}
}
return {
heroBackground: 'var(--accent-yellow-light, #FFF3E0)',
heroIcon: <AlertTriangle size={28} style={{ color: 'var(--accent-orange)' }} />,
openFolderLabel: 'Choose a different folder',
subtitle: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.',
templateDescription: isOffline
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
: `Download the starter vault template — suggested path: ${defaultVaultPath}`,
title: 'Vault not found',
}
}
export function WelcomeScreen({
mode,
defaultVaultPath,
@@ -196,12 +237,13 @@ export function WelcomeScreen({
onRetryCreateVault,
onCreateNewVault,
onOpenFolder,
isOffline,
creatingAction,
error,
canRetryTemplate,
}: WelcomeScreenProps) {
const isWelcome = mode === 'welcome'
const busy = creatingAction !== null
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
return (
<div style={CONTAINER_STYLE} data-testid="welcome-screen">
@@ -209,24 +251,16 @@ export function WelcomeScreen({
<div
style={{
...ICON_WRAP_STYLE,
background: isWelcome ? 'var(--accent-blue-light, #EBF4FF)' : 'var(--accent-yellow-light, #FFF3E0)',
background: presentation.heroBackground,
}}
>
{isWelcome
? <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>&#10022;</span>
: <AlertTriangle size={28} style={{ color: 'var(--accent-orange)' }} />
}
{presentation.heroIcon}
</div>
<div style={{ textAlign: 'center' }}>
<h1 style={TITLE_STYLE}>
{isWelcome ? 'Welcome to Tolaria' : 'Vault not found'}
</h1>
<h1 style={TITLE_STYLE}>{presentation.title}</h1>
<p style={{ ...SUBTITLE_STYLE, marginTop: 8 }}>
{isWelcome
? 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.'
: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.'
}
{presentation.subtitle}
</p>
</div>
@@ -249,7 +283,7 @@ export function WelcomeScreen({
<OptionButton
icon={<FolderOpen size={18} style={{ color: 'var(--accent-green)' }} />}
iconBg="var(--accent-green-light, #E8F5E9)"
label={isWelcome ? 'Open existing vault' : 'Choose a different folder'}
label={presentation.openFolderLabel}
description="Point to a folder you already have"
onClick={onOpenFolder}
disabled={busy}
@@ -260,11 +294,11 @@ 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={presentation.templateDescription}
loadingLabel="Downloading template…"
loadingDescription="Cloning the Getting Started vault from GitHub"
loadingDescription="Cloning the Getting Started vault template"
onClick={onCreateVault}
disabled={busy}
disabled={busy || isOffline}
loading={creatingAction === 'template'}
testId="welcome-create-vault"
/>
@@ -272,7 +306,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>
)}

View File

@@ -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')
})
})

View File

@@ -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}
/>
)
}

View File

@@ -62,13 +62,14 @@ function RawModeEditorSection({
activeTab,
entries,
rawMode,
rawModeContent,
onRawContentChange,
onSave,
rawLatestContentRef,
vaultPath,
}: Pick<
EditorContentModel,
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'vaultPath'
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath'
> & {
rawMode: boolean
}) {
@@ -77,7 +78,7 @@ function RawModeEditorSection({
return (
<RawEditorView
key={activeTab.entry.path}
content={activeTab.content}
content={rawModeContent ?? activeTab.content}
path={activeTab.entry.path}
entries={entries}
onContentChange={onRawContentChange ?? (() => {})}
@@ -226,6 +227,7 @@ export function EditorContentLayout(model: EditorContentModel) {
onEditorChange,
isDeletedPreview,
rawLatestContentRef,
rawModeContent,
} = model
if (!activeTab) {
@@ -278,6 +280,7 @@ export function EditorContentLayout(model: EditorContentModel) {
activeTab={activeTab}
entries={entries}
rawMode={effectiveRawMode}
rawModeContent={rawModeContent}
onRawContentChange={onRawContentChange}
onSave={onSave}
rawLatestContentRef={rawLatestContentRef}

View File

@@ -37,6 +37,7 @@ export interface EditorContentProps {
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawModeContent?: string | null
rawLatestContentRef?: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean

View File

@@ -0,0 +1,116 @@
import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
interface Tab {
entry: VaultEntry
content: string
}
export interface PendingRawExitContent {
path: string
content: string
}
export function buildPendingRawExitContent(
path: string | null,
content: string | null,
): PendingRawExitContent | null {
if (!path || content === null) return null
return { path, content }
}
export function serializeEditorDocumentToMarkdown(
editor: ReturnType<typeof useCreateBlockNote>,
tabContent: string,
): string {
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const [frontmatter] = splitFrontmatter(tabContent)
return `${frontmatter}${bodyMarkdown}`
}
export function applyPendingRawExitContent(
tabs: Tab[],
pendingRawExitContent: PendingRawExitContent | null,
): Tab[] {
if (!pendingRawExitContent) return tabs
let changed = false
const nextTabs = tabs.map((tab) => {
if (tab.entry.path !== pendingRawExitContent.path || tab.content === pendingRawExitContent.content) {
return tab
}
changed = true
return {
...tab,
content: pendingRawExitContent.content,
}
})
return changed ? nextTabs : tabs
}
export function syncActiveTabIntoRawBuffer(options: {
editor: ReturnType<typeof useCreateBlockNote>
activeTabPath: string | null
activeTabContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onContentChange?: (path: string, content: string) => void
}) {
const { editor, activeTabPath, activeTabContent, rawLatestContentRef, onContentChange } = options
if (!activeTabPath || activeTabContent === null) return null
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent)
rawLatestContentRef.current = syncedContent
onContentChange?.(activeTabPath, syncedContent)
return syncedContent
}
export function rememberPendingRawExitContent(options: {
activeTabPath: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onContentChange?: (path: string, content: string) => void
}) {
const { activeTabPath, rawLatestContentRef, onContentChange } = options
const pendingRawExitContent = buildPendingRawExitContent(activeTabPath, rawLatestContentRef.current)
if (!pendingRawExitContent) return null
onContentChange?.(pendingRawExitContent.path, pendingRawExitContent.content)
return pendingRawExitContent
}
export function resolvePendingRawExitContent(options: {
activeTabPath: string | null
tabs: Tab[]
pendingRawExitContent: PendingRawExitContent | null
}) {
const { activeTabPath, tabs, pendingRawExitContent } = options
if (!pendingRawExitContent) return null
if (activeTabPath !== pendingRawExitContent.path) {
return null
}
const syncedTab = tabs.find((tab) => tab.entry.path === pendingRawExitContent.path)
if (syncedTab?.content === pendingRawExitContent.content) {
return null
}
return pendingRawExitContent
}
export function resolveRawModeContent(options: {
activeTab: Tab | null
rawModeContentOverride: PendingRawExitContent | null
}) {
const { activeTab, rawModeContentOverride } = options
if (!activeTab) return null
if (rawModeContentOverride?.path === activeTab.entry.path) {
return rawModeContentOverride.content
}
return activeTab.content
}

View 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 }
}

View 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 })
}

View 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)
}

View 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
}

View 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, ' ')
}

View File

@@ -122,6 +122,16 @@ function hasRemote(remoteStatus: GitRemoteStatus | null): boolean {
return remoteStatus?.hasRemote ?? false
}
function isRemoteMissing(remoteStatus: GitRemoteStatus | null | undefined): boolean {
return remoteStatus?.hasRemote === false
}
function commitButtonTitle(remoteStatus: GitRemoteStatus | null | undefined): string {
return isRemoteMissing(remoteStatus)
? 'Commit locally (no remote configured)'
: 'Commit & Push'
}
function getMcpBadgeConfig(status: McpStatus, onInstall?: () => void) {
if (status === 'installed' || status === 'checking') return null
const clickable = status === 'not_installed' && Boolean(onInstall)
@@ -304,6 +314,58 @@ export function CommitBadge({ info }: { info: LastCommitInfo }) {
)
}
export function OfflineBadge({ isOffline }: { isOffline?: boolean }) {
if (!isOffline) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
style={{
...ICON_STYLE,
color: 'var(--destructive, #e03e3e)',
background: 'rgba(224, 62, 62, 0.12)',
borderRadius: 999,
padding: '2px 6px',
fontWeight: 600,
}}
title="No internet connection"
data-testid="status-offline"
>
<span aria-hidden="true" style={{ fontSize: 10, lineHeight: 1 }}>
</span>
Offline
</span>
</>
)
}
export function NoRemoteBadge({ remoteStatus }: { remoteStatus?: GitRemoteStatus | null }) {
if (!isRemoteMissing(remoteStatus)) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
style={{
...ICON_STYLE,
color: 'var(--muted-foreground)',
background: 'var(--hover)',
borderRadius: 999,
padding: '2px 6px',
fontWeight: 600,
}}
title="This git vault has no remote configured. Commits stay local until you add one."
data-testid="status-no-remote"
>
<GitBranch size={12} />
No remote
</span>
</>
)
}
export function SyncBadge({
status,
lastSyncTime,
@@ -432,7 +494,13 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () =
)
}
export function CommitButton({ onClick }: { onClick?: () => void }) {
export function CommitButton({
onClick,
remoteStatus,
}: {
onClick?: () => void
remoteStatus?: GitRemoteStatus | null
}) {
if (!onClick) return null
return (
@@ -442,7 +510,7 @@ export function CommitButton({ onClick }: { onClick?: () => void }) {
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Commit & Push"
title={commitButtonTitle(remoteStatus)}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
data-testid="status-commit-push"

View File

@@ -11,6 +11,8 @@ import {
ConflictBadge,
ChangesBadge,
McpBadge,
NoRemoteBadge,
OfflineBadge,
PulseBadge,
SyncBadge,
} from './StatusBarBadges'
@@ -24,11 +26,12 @@ interface StatusBarPrimarySectionProps {
vaults: VaultOption[]
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onConnectGitHub?: () => void
hasGitHub?: boolean
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onClickPending?: () => void
onClickPulse?: () => void
onCommitPush?: () => void
isOffline?: boolean
isGitVault?: boolean
syncStatus: SyncStatus
lastSyncTime: number | null
@@ -61,11 +64,12 @@ export function StatusBarPrimarySection({
vaults,
onSwitchVault,
onOpenLocalFolder,
onConnectGitHub,
hasGitHub,
onCloneVault,
onCloneGettingStarted,
onClickPending,
onClickPulse,
onCommitPush,
isOffline = false,
isGitVault = false,
syncStatus,
lastSyncTime,
@@ -90,8 +94,8 @@ export function StatusBarPrimarySection({
vaultPath={vaultPath}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onConnectGitHub={onConnectGitHub}
hasGitHub={hasGitHub}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onRemoveVault={onRemoveVault}
/>
<span style={SEP_STYLE}>|</span>
@@ -107,8 +111,10 @@ export function StatusBarPrimarySection({
<Package size={13} />
{buildNumber ?? 'b?'}
</span>
<OfflineBadge isOffline={isOffline} />
<NoRemoteBadge remoteStatus={remoteStatus} />
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
<CommitButton onClick={onCommitPush} />
<CommitButton onClick={onCommitPush} remoteStatus={remoteStatus} />
<SyncBadge
status={syncStatus}
lastSyncTime={lastSyncTime}

View File

@@ -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, Rocket, X } from 'lucide-react'
import type { VaultOption } from './types'
import { useDismissibleLayer } from './useDismissibleLayer'
@@ -9,8 +9,8 @@ interface VaultMenuProps {
vaultPath: string
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onConnectGitHub?: () => void
hasGitHub?: boolean
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onRemoveVault?: (path: string) => void
}
@@ -39,6 +39,47 @@ interface VaultAction {
onClick: () => void
}
function buildVaultActions({
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
}: Pick<VaultMenuProps, 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
const items: VaultAction[] = []
if (onOpenLocalFolder) {
items.push({
key: 'open-local',
icon: <FolderOpen size={12} />,
label: 'Open local folder',
testId: 'vault-menu-open-local',
onClick: onOpenLocalFolder,
})
}
if (onCloneVault) {
items.push({
key: 'clone-git',
icon: <GitBranch size={12} />,
label: 'Clone Git repo',
testId: 'vault-menu-clone-git',
onClick: onCloneVault,
})
}
if (onCloneGettingStarted) {
items.push({
key: 'clone-getting-started',
icon: <Rocket size={12} />,
label: 'Clone Getting Started Vault',
testId: 'vault-menu-clone-getting-started',
accent: true,
onClick: onCloneGettingStarted,
})
}
return items
}
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
if (isActive) return <Check size={12} />
if (unavailable) return <AlertTriangle size={12} style={{ color: 'var(--muted-foreground)' }} />
@@ -142,8 +183,8 @@ export function VaultMenu({
vaultPath,
onSwitchVault,
onOpenLocalFolder,
onConnectGitHub,
hasGitHub,
onCloneVault,
onCloneGettingStarted,
onRemoveVault,
}: VaultMenuProps) {
const [open, setOpen] = useState(false)
@@ -154,31 +195,12 @@ export function VaultMenu({
useDismissibleLayer(open, menuRef, () => setOpen(false))
const actions = useMemo<VaultAction[]>(() => {
const items: VaultAction[] = []
if (onOpenLocalFolder) {
items.push({
key: 'open-local',
icon: <FolderOpen size={12} />,
label: 'Open local folder',
testId: 'vault-menu-open-local',
onClick: onOpenLocalFolder,
})
}
if (onConnectGitHub) {
items.push({
key: 'connect-github',
icon: <Github size={12} />,
label: 'Connect GitHub repo',
testId: 'vault-menu-connect-github',
accent: !hasGitHub,
onClick: onConnectGitHub,
})
}
return items
}, [hasGitHub, onConnectGitHub, onOpenLocalFolder])
return buildVaultActions({
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
})
}, [onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
return (
<div ref={menuRef} style={{ position: 'relative' }}>

View File

@@ -0,0 +1,22 @@
import type { useCreateBlockNote } from '@blocknote/react'
export function clearTableResizeState(editor: ReturnType<typeof useCreateBlockNote>) {
const view = editor._tiptapEditor?.view
if (!view || view.isDestroyed) return
const resizePluginKey = view.state.plugins.find((plugin: { key?: unknown }) => (
typeof plugin.key === 'string' && plugin.key.startsWith('tableColumnResizing')
))?.key
if (!resizePluginKey) return
try {
view.dispatch(
view.state.tr.setMeta(resizePluginKey, {
setHandle: -1,
setDragging: null,
}),
)
} catch (error) {
console.warn('Failed to clear table resize state before raw mode toggle:', error)
}
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex min-h-20 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
),
)
Textarea.displayName = "Textarea"
export { Textarea }

View 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 }
}

View 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,
}
}

View 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])
}

View 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])
}

View 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,
}
}

View 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,
}
}

View 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])
}

View File

@@ -0,0 +1,54 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useClaudeCodeOnboarding } from './useClaudeCodeOnboarding'
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
const DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
describe('useClaudeCodeOnboarding', () => {
beforeEach(() => {
localStorage.clear()
})
it('shows the prompt when enabled and not dismissed', () => {
const { result } = renderHook(() => useClaudeCodeOnboarding(true))
expect(result.current.showPrompt).toBe(true)
})
it('hides the prompt when onboarding is disabled', () => {
const { result } = renderHook(() => useClaudeCodeOnboarding(false))
expect(result.current.showPrompt).toBe(false)
})
it('starts hidden when the prompt was already dismissed', () => {
localStorage.setItem(DISMISSED_KEY, '1')
const { result } = renderHook(() => useClaudeCodeOnboarding(true))
expect(result.current.showPrompt).toBe(false)
})
it('persists dismissal and hides the prompt', () => {
const { result } = renderHook(() => useClaudeCodeOnboarding(true))
act(() => {
result.current.dismissPrompt()
})
expect(result.current.showPrompt).toBe(false)
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
})
})

View File

@@ -0,0 +1,33 @@
import { useCallback, useState } from 'react'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
function wasDismissed(): boolean {
try {
return localStorage.getItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY) === '1'
} catch {
return false
}
}
function markDismissed(): void {
try {
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
} catch {
// localStorage may be unavailable in restricted contexts
}
}
export function useClaudeCodeOnboarding(enabled: boolean) {
const [dismissed, setDismissed] = useState(() => wasDismissed())
const dismissPrompt = useCallback(() => {
markDismissed()
setDismissed(true)
}, [])
return {
dismissPrompt,
showPrompt: enabled && !dismissed,
}
}

View File

@@ -2,28 +2,58 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCommitFlow } from './useCommitFlow'
const mockInvokeFn = vi.fn()
const mockTrackEvent = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: (event: string, properties?: Record<string, unknown>) => mockTrackEvent(event, properties),
}))
describe('useCommitFlow', () => {
let savePending: vi.Mock
let loadModifiedFiles: vi.Mock
let commitAndPush: vi.Mock
let resolveRemoteStatus: vi.Mock
let setToastMessage: vi.Mock
let onPushRejected: vi.Mock
beforeEach(() => {
savePending = vi.fn().mockResolvedValue(undefined)
loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
commitAndPush = vi.fn().mockResolvedValue({ status: 'ok', message: 'Pushed to remote' })
resolveRemoteStatus = vi.fn().mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: true })
setToastMessage = vi.fn()
onPushRejected = vi.fn()
mockTrackEvent.mockReset()
mockInvokeFn.mockReset()
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.resolve('[main abc1234] test commit')
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
throw new Error(`Unexpected command: ${command}`)
})
})
function renderCommitFlow() {
return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }))
return renderHook(() => useCommitFlow({
savePending,
loadModifiedFiles,
resolveRemoteStatus,
setToastMessage,
onPushRejected,
vaultPath: '/vault',
}))
}
it('openCommitDialog saves pending, refreshes files, then opens dialog', async () => {
it('openCommitDialog saves pending, refreshes files, and sets local mode when no remote exists', async () => {
resolveRemoteStatus.mockResolvedValueOnce({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
const { result } = renderCommitFlow()
expect(result.current.showCommitDialog).toBe(false)
await act(async () => {
await result.current.openCommitDialog()
@@ -31,10 +61,12 @@ describe('useCommitFlow', () => {
expect(savePending).toHaveBeenCalledTimes(1)
expect(loadModifiedFiles).toHaveBeenCalledTimes(1)
expect(resolveRemoteStatus).toHaveBeenCalledTimes(1)
expect(result.current.showCommitDialog).toBe(true)
expect(result.current.commitMode).toBe('local')
})
it('handleCommitPush saves pending, commits, shows toast, and refreshes files', async () => {
it('handleCommitPush commits and pushes when a remote is configured', async () => {
const { result } = renderCommitFlow()
await act(async () => {
@@ -42,14 +74,39 @@ describe('useCommitFlow', () => {
})
expect(savePending).toHaveBeenCalled()
expect(commitAndPush).toHaveBeenCalledWith('test message')
expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'git_commit', { vaultPath: '/vault', message: 'test message' })
expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_push', { vaultPath: '/vault' })
expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed')
expect(loadModifiedFiles).toHaveBeenCalled()
expect(resolveRemoteStatus).toHaveBeenCalledTimes(2)
expect(mockTrackEvent).toHaveBeenCalledWith('commit_made', undefined)
expect(result.current.showCommitDialog).toBe(false)
})
it('handleCommitPush commits locally and skips push when no remote is configured', async () => {
resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.resolve('[main abc1234] test message')
throw new Error(`Unexpected command: ${command}`)
})
const { result } = renderCommitFlow()
await act(async () => {
await result.current.handleCommitPush('test message')
})
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
expect(mockInvokeFn).toHaveBeenCalledWith('git_commit', { vaultPath: '/vault', message: 'test message' })
expect(setToastMessage).toHaveBeenCalledWith('Committed locally (no remote configured)')
expect(onPushRejected).not.toHaveBeenCalled()
})
it('handleCommitPush calls onPushRejected when push is rejected', async () => {
commitAndPush.mockResolvedValueOnce({ status: 'rejected', message: 'Push rejected' })
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.resolve('[main abc1234] test message')
if (command === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected' })
throw new Error(`Unexpected command: ${command}`)
})
const { result } = renderCommitFlow()
await act(async () => {
@@ -61,7 +118,7 @@ describe('useCommitFlow', () => {
})
it('handleCommitPush shows error toast on failure', async () => {
commitAndPush.mockRejectedValueOnce(new Error('push failed'))
mockInvokeFn.mockImplementation(() => Promise.reject(new Error('push failed')))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderCommitFlow()
@@ -69,7 +126,7 @@ describe('useCommitFlow', () => {
await result.current.handleCommitPush('test')
})
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Commit failed'))
expect(setToastMessage).toHaveBeenCalledWith('Commit failed: push failed')
consoleSpy.mockRestore()
})

View File

@@ -1,47 +1,115 @@
import { useCallback, useState } from 'react'
import type { GitPushResult } from '../types'
import { invoke } from '@tauri-apps/api/core'
import type { GitPushResult, GitRemoteStatus } from '../types'
import { trackEvent } from '../lib/telemetry'
import { isTauri, mockInvoke } from '../mock-tauri'
export type CommitMode = 'push' | 'local'
interface LocalCommitResult {
status: 'local_only'
message: string
}
type CommitResult = GitPushResult | LocalCommitResult
interface CommitFlowConfig {
savePending: () => Promise<void | boolean>
loadModifiedFiles: () => Promise<void>
commitAndPush: (message: string) => Promise<GitPushResult>
resolveRemoteStatus: () => Promise<GitRemoteStatus | null>
setToastMessage: (msg: string | null) => void
onPushRejected?: () => void
vaultPath: string
}
/** Manages the Commit & Push dialog state and the save→commit→push flow. */
export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }: CommitFlowConfig) {
function commitModeFromRemoteStatus(remoteStatus: GitRemoteStatus | null): CommitMode {
return remoteStatus?.hasRemote === false ? 'local' : 'push'
}
async function commitLocally(vaultPath: string, message: string): Promise<void> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { vaultPath, message })
return
}
await invoke<string>('git_commit', { vaultPath, message })
}
async function pushCommittedChanges(vaultPath: string): Promise<GitPushResult> {
if (!isTauri()) {
return mockInvoke<GitPushResult>('git_push', { vaultPath })
}
return invoke<GitPushResult>('git_push', { vaultPath })
}
async function executeCommitAction(vaultPath: string, message: string, commitMode: CommitMode): Promise<CommitResult> {
await commitLocally(vaultPath, message)
if (commitMode === 'local') {
return { status: 'local_only', message: 'Committed locally (no remote configured)' }
}
return pushCommittedChanges(vaultPath)
}
function commitToastMessage(result: CommitResult): string {
if (result.status === 'ok') return 'Committed and pushed'
if (result.status === 'local_only') return result.message
if (result.status === 'rejected') return 'Committed, but push rejected — remote has new commits. Pull first.'
return result.message
}
function isPushRejected(result: CommitResult): boolean {
return result.status === 'rejected'
}
function formatCommitError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** Manages the commit dialog state and the save→commit→push/local flow. */
export function useCommitFlow({
savePending,
loadModifiedFiles,
resolveRemoteStatus,
setToastMessage,
onPushRejected,
vaultPath,
}: CommitFlowConfig) {
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [commitMode, setCommitMode] = useState<CommitMode>('push')
const openCommitDialog = useCallback(async () => {
await savePending()
await loadModifiedFiles()
const remoteStatus = await resolveRemoteStatus()
setCommitMode(commitModeFromRemoteStatus(remoteStatus))
setShowCommitDialog(true)
}, [savePending, loadModifiedFiles])
}, [loadModifiedFiles, resolveRemoteStatus, savePending])
const handleCommitPush = useCallback(async (message: string) => {
setShowCommitDialog(false)
try {
await savePending()
const result = await commitAndPush(message)
if (result.status === 'ok') {
trackEvent('commit_made')
setToastMessage('Committed and pushed')
} else if (result.status === 'rejected') {
setToastMessage('Committed, but push rejected — remote has new commits. Pull first.')
const remoteStatus = await resolveRemoteStatus()
const nextCommitMode = commitModeFromRemoteStatus(remoteStatus)
const result = await executeCommitAction(vaultPath, message, nextCommitMode)
trackEvent('commit_made')
setToastMessage(commitToastMessage(result))
if (isPushRejected(result)) {
onPushRejected?.()
} else {
setToastMessage(result.message)
}
loadModifiedFiles()
await loadModifiedFiles()
await resolveRemoteStatus()
} catch (err) {
console.error('Commit failed:', err)
setToastMessage(`Commit failed: ${err}`)
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
}
}, [savePending, commitAndPush, loadModifiedFiles, setToastMessage, onPushRejected])
}, [loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath])
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])
return { showCommitDialog, openCommitDialog, handleCommitPush, closeCommitDialog }
return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog }
}

View File

@@ -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,

View File

@@ -0,0 +1,49 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { useGitRemoteStatus } from './useGitRemoteStatus'
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
describe('useGitRemoteStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads remote status on mount', async () => {
mockInvokeFn.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
const { result } = renderHook(() => useGitRemoteStatus('/vault'))
await waitFor(() => {
expect(result.current.remoteStatus).toEqual({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
})
expect(mockInvokeFn).toHaveBeenCalledWith('git_remote_status', { vaultPath: '/vault' })
})
it('refreshRemoteStatus updates the current remote state', async () => {
mockInvokeFn
.mockResolvedValueOnce({ branch: 'main', ahead: 0, behind: 0, hasRemote: true })
.mockResolvedValueOnce({ branch: 'main', ahead: 1, behind: 0, hasRemote: false })
const { result } = renderHook(() => useGitRemoteStatus('/vault'))
await waitFor(() => {
expect(result.current.remoteStatus?.hasRemote).toBe(true)
})
await act(async () => {
await result.current.refreshRemoteStatus()
})
expect(result.current.remoteStatus).toEqual({ branch: 'main', ahead: 1, behind: 0, hasRemote: false })
})
})

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { GitRemoteStatus } from '../types'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
export interface GitRemoteState {
remoteStatus: GitRemoteStatus | null
refreshRemoteStatus: () => Promise<GitRemoteStatus | null>
}
async function readRemoteStatus(vaultPath: string): Promise<GitRemoteStatus> {
return tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
}
export function useGitRemoteStatus(vaultPath: string): GitRemoteState {
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null)
const refreshRemoteStatus = useCallback(async () => {
try {
const status = await readRemoteStatus(vaultPath)
setRemoteStatus(status)
return status
} catch {
setRemoteStatus(null)
return null
}
}, [vaultPath])
useEffect(() => {
let cancelled = false
async function loadRemoteStatus() {
try {
const status = await readRemoteStatus(vaultPath)
if (!cancelled) setRemoteStatus(status)
} catch {
if (!cancelled) setRemoteStatus(null)
}
}
void loadRemoteStatus()
return () => {
cancelled = true
}
}, [vaultPath])
return { remoteStatus, refreshRemoteStatus }
}

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import type { AppCommandShortcutEventInit, AppCommandShortcutEventOptions } from './appCommandCatalog'
import {
APP_COMMAND_EVENT_NAME,
executeAppCommand,
@@ -8,18 +7,6 @@ import {
type AppCommandHandlers,
} from './appCommandDispatcher'
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
}
}
}
export interface MenuEventHandlers extends AppCommandHandlers {
activeTabPath: string | null
modifiedCount?: number

View File

@@ -0,0 +1,43 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useNetworkStatus } from './useNetworkStatus'
describe('useNetworkStatus', () => {
let online = true
beforeEach(() => {
online = true
Object.defineProperty(window.navigator, 'onLine', {
configurable: true,
get: () => online,
})
})
it('uses navigator.onLine for the initial state', () => {
online = false
const { result } = renderHook(() => useNetworkStatus())
expect(result.current.isOffline).toBe(true)
})
it('updates when online and offline events fire', () => {
const { result } = renderHook(() => useNetworkStatus())
expect(result.current.isOffline).toBe(false)
act(() => {
online = false
window.dispatchEvent(new Event('offline'))
})
expect(result.current.isOffline).toBe(true)
act(() => {
online = true
window.dispatchEvent(new Event('online'))
})
expect(result.current.isOffline).toBe(false)
})
})

View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react'
function detectOffline(): boolean {
if (typeof navigator === 'undefined') {
return false
}
return navigator.onLine === false
}
export function useNetworkStatus() {
const [isOffline, setIsOffline] = useState(detectOffline)
useEffect(() => {
const handleOnline = () => setIsOffline(false)
const handleOffline = () => setIsOffline(true)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return { isOffline }
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -321,7 +321,7 @@ describe('useVaultSwitcher', () => {
})
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
expect(onToast).toHaveBeenCalledWith('Getting Started vault restored')
expect(onToast).toHaveBeenCalledWith('Getting Started vault ready')
})
it('attempts to create vault on disk if it does not exist', async () => {
@@ -351,6 +351,37 @@ describe('useVaultSwitcher', () => {
expect(mockInvokeFn).toHaveBeenCalledWith('check_vault_exists', { path: DEFAULT_VAULTS[0].path })
expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', { targetPath: DEFAULT_VAULTS[0].path })
})
it('shows a friendly toast and keeps the hidden vault hidden when cloning fails', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [DEFAULT_VAULTS[0].path],
}
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'check_vault_exists') return Promise.resolve(false)
if (cmd === 'create_getting_started_vault') {
return Promise.reject('git clone failed: fatal: unable to access')
}
return Promise.resolve(null)
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
await act(async () => {
await result.current.restoreGettingStarted()
})
expect(result.current.vaultPath).toBe('/work/vault')
expect(result.current.isGettingStartedHidden).toBe(true)
expect(onToast).toHaveBeenCalledWith('Getting Started requires internet. Clone it later.')
})
})
describe('default vault path', () => {

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
@@ -25,6 +26,74 @@ interface UseVaultSwitcherOptions {
onToast: (msg: string) => void
}
interface PersistedVaultState {
defaultPath: string
extraVaults: VaultOption[]
hiddenDefaults: string[]
loaded: boolean
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
interface VaultCollections {
allVaults: VaultOption[]
defaultVaults: VaultOption[]
isGettingStartedHidden: boolean
}
interface PersistedVaultStore {
defaultPath: string
extraVaults: VaultOption[]
hiddenDefaults: string[]
loaded: boolean
setDefaultPath: Dispatch<SetStateAction<string>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setLoaded: Dispatch<SetStateAction<boolean>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
interface VaultActionOptions extends PersistedVaultState, VaultCollections {
onSwitchRef: MutableRefObject<() => void>
onToastRef: MutableRefObject<(msg: string) => void>
}
interface RestoreGettingStartedOptions {
defaultPath: string
onToastRef: MutableRefObject<(msg: string) => void>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
switchVault: (path: string) => void
}
interface RemainingVaultOptions {
defaultVaults: VaultOption[]
extraVaults: VaultOption[]
hiddenDefaults: string[]
isDefault: boolean
removedPath: string
}
interface RemoveVaultStateOptions extends RemainingVaultOptions {
onSwitchRef: MutableRefObject<() => void>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setVaultPath: Dispatch<SetStateAction<string>>
}
interface RemoveVaultActionOptions {
defaultVaults: VaultOption[]
extraVaults: VaultOption[]
hiddenDefaults: string[]
onSwitchRef: MutableRefObject<() => void>
onToastRef: MutableRefObject<(msg: string) => void>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setVaultPath: Dispatch<SetStateAction<string>>
}
function labelFromPath(path: string): string {
return path.split('/').pop() || 'Local Vault'
}
@@ -33,153 +102,461 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
/** Manages vault path, extra vaults, switching, cloning, and local folder opening.
* Vault list and active vault are persisted via Tauri backend to survive app updates. */
export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) {
async function resolveDefaultPath(): Promise<string> {
if (STATIC_DEFAULT_PATH) {
return STATIC_DEFAULT_PATH
}
try {
return await tauriCall<string>('get_default_vault_path', {})
} catch {
return ''
}
}
function syncDefaultVaultExport(path: string) {
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path }
}
async function loadInitialVaultState() {
const [{ vaults, activeVault, hiddenDefaults }, resolvedDefaultPath] = await Promise.all([
loadVaultList(),
resolveDefaultPath(),
])
return { activeVault, hiddenDefaults, resolvedDefaultPath, vaults }
}
function buildDefaultVaults(defaultPath: string): VaultOption[] {
return [{ label: GETTING_STARTED_LABEL, path: defaultPath }]
}
function buildVisibleDefaultVaults(defaultVaults: VaultOption[], hiddenDefaults: string[]): VaultOption[] {
return defaultVaults.filter(vault => !hiddenDefaults.includes(vault.path))
}
function buildAllVaults(visibleDefaults: VaultOption[], extraVaults: VaultOption[]): VaultOption[] {
return [...visibleDefaults, ...extraVaults]
}
function applyResolvedDefaultPath(
resolvedDefaultPath: string,
setDefaultPath: Dispatch<SetStateAction<string>>,
) {
if (!resolvedDefaultPath) {
return
}
setDefaultPath(resolvedDefaultPath)
syncDefaultVaultExport(resolvedDefaultPath)
}
function applyInitialVaultTarget(
activeVault: string | null,
resolvedDefaultPath: string,
setVaultPath: Dispatch<SetStateAction<string>>,
onSwitchRef: MutableRefObject<() => void>,
) {
if (activeVault) {
setVaultPath(activeVault)
onSwitchRef.current()
return
}
if (resolvedDefaultPath) {
setVaultPath(resolvedDefaultPath)
}
}
function useVaultCollections(
defaultPath: string,
hiddenDefaults: string[],
extraVaults: VaultOption[],
): VaultCollections {
const defaultVaults = useMemo(
() => buildDefaultVaults(defaultPath),
[defaultPath],
)
const visibleDefaults = useMemo(
() => buildVisibleDefaultVaults(defaultVaults, hiddenDefaults),
[defaultVaults, hiddenDefaults],
)
const allVaults = useMemo(
() => buildAllVaults(visibleDefaults, extraVaults),
[extraVaults, visibleDefaults],
)
const isGettingStartedHidden = useMemo(
() => hiddenDefaults.includes(defaultPath),
[defaultPath, hiddenDefaults],
)
return { allVaults, defaultVaults, isGettingStartedHidden }
}
function useLoadPersistedVaultState(
store: PersistedVaultStore,
onSwitchRef: MutableRefObject<() => void>,
) {
const {
setDefaultPath,
setExtraVaults,
setHiddenDefaults,
setLoaded,
setVaultPath,
} = store
useEffect(() => {
let cancelled = false
loadInitialVaultState()
.then(({ activeVault, hiddenDefaults: hidden, resolvedDefaultPath, vaults }) => {
if (cancelled) return
setExtraVaults(vaults)
setHiddenDefaults(hidden)
applyResolvedDefaultPath(resolvedDefaultPath, setDefaultPath)
applyInitialVaultTarget(activeVault, resolvedDefaultPath, setVaultPath, onSwitchRef)
})
.catch(err => console.warn('Failed to load vault list:', err))
.finally(() => {
if (!cancelled) {
setLoaded(true)
}
})
return () => { cancelled = true }
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setVaultPath])
}
function usePersistedVaultStorage(store: PersistedVaultStore) {
const { extraVaults, hiddenDefaults, loaded, vaultPath } = store
useEffect(() => {
if (!loaded) return
saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err =>
console.warn('Failed to persist vault list:', err),
)
}, [extraVaults, hiddenDefaults, loaded, vaultPath])
}
function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): PersistedVaultState {
const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH)
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
const [loaded, setLoaded] = useState(false)
const [defaultPath, setDefaultPath] = useState(STATIC_DEFAULT_PATH)
const defaultVaults: VaultOption[] = useMemo(
() => [{ label: GETTING_STARTED_LABEL, path: defaultPath }],
[defaultPath],
)
const store: PersistedVaultStore = {
defaultPath,
extraVaults,
hiddenDefaults,
loaded,
setDefaultPath,
setExtraVaults,
setHiddenDefaults,
setLoaded,
setVaultPath,
vaultPath,
}
const visibleDefaults = useMemo(
() => defaultVaults.filter(v => !hiddenDefaults.includes(v.path)),
[defaultVaults, hiddenDefaults],
)
const allVaults = useMemo(
() => [...visibleDefaults, ...extraVaults],
[visibleDefaults, extraVaults],
)
useLoadPersistedVaultState(store, onSwitchRef)
usePersistedVaultStorage(store)
const isGettingStartedHidden = useMemo(
() => hiddenDefaults.includes(defaultPath),
[hiddenDefaults, defaultPath],
)
return {
defaultPath,
extraVaults,
hiddenDefaults,
loaded,
setExtraVaults,
setHiddenDefaults,
setVaultPath,
vaultPath,
}
}
const onSwitchRef = useRef(onSwitch)
const onToastRef = useRef(onToast)
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
function formatGettingStartedRestoreError(err: unknown): string {
const message =
typeof err === 'string'
? err
: err instanceof Error
? err.message
: `${err}`
const hasLoadedRef = useRef(false)
const networkErrors = [
'unable to access',
'Could not resolve host',
'network',
'timed out',
]
useEffect(() => {
let cancelled = false
loadVaultList()
.then(async ({ vaults, activeVault, hiddenDefaults: hidden }) => {
if (cancelled) return
setExtraVaults(vaults)
setHiddenDefaults(hidden)
if (activeVault) {
setVaultPath(activeVault)
onSwitchRef.current()
} else if (!STATIC_DEFAULT_PATH) {
// Production build: resolve the Getting Started path at runtime
try {
const runtimePath = await tauriCall<string>('get_default_vault_path', {})
if (!cancelled && runtimePath) {
setDefaultPath(runtimePath)
setVaultPath(runtimePath)
// Keep the module-level export in sync for external consumers
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path: runtimePath }
}
} catch {
// In mock/test mode, command may not exist
}
}
})
.catch(err => console.warn('Failed to load vault list:', err))
.finally(() => {
hasLoadedRef.current = true
setLoaded(true)
})
return () => { cancelled = true }
}, [])
if (networkErrors.some(fragment => message.includes(fragment))) {
return 'Getting Started requires internet. Clone it later.'
}
useEffect(() => {
if (!hasLoadedRef.current) return
saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err =>
console.warn('Failed to persist vault list:', err),
)
}, [extraVaults, vaultPath, hiddenDefaults])
return `Could not prepare Getting Started vault: ${message}`
}
const addVault = useCallback((path: string, label: string) => {
setExtraVaults(prev => {
const exists = prev.some(v => v.path === path)
return exists ? prev : [...prev, { label, path, available: true }]
async function ensureGettingStartedVaultReady(path: string): Promise<void> {
const exists = await tauriCall<boolean>('check_vault_exists', { path })
if (!exists) {
await tauriCall<string>('create_getting_started_vault', { targetPath: path })
}
}
function addVaultToList(
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>,
path: string,
label: string,
) {
setExtraVaults(previousVaults => {
const exists = previousVaults.some(vault => vault.path === path)
return exists ? previousVaults : [...previousVaults, { label, path, available: true }]
})
}
function switchVaultPath(
setVaultPath: Dispatch<SetStateAction<string>>,
onSwitchRef: MutableRefObject<() => void>,
path: string,
) {
trackEvent('vault_switched')
setVaultPath(path)
onSwitchRef.current()
}
function listRemainingVaults({
defaultVaults,
extraVaults,
hiddenDefaults,
isDefault,
removedPath,
}: RemainingVaultOptions) {
const visibleDefaults = defaultVaults.filter(vault => (
vault.path !== removedPath
&& (!isDefault || !hiddenDefaults.includes(vault.path))
))
return [...visibleDefaults, ...extraVaults.filter(vault => vault.path !== removedPath)]
}
function removeVaultFromState({
defaultVaults,
extraVaults,
hiddenDefaults,
isDefault,
onSwitchRef,
removedPath,
setExtraVaults,
setHiddenDefaults,
setVaultPath,
}: RemoveVaultStateOptions) {
if (isDefault) {
setHiddenDefaults(previousHidden => previousHidden.includes(removedPath) ? previousHidden : [...previousHidden, removedPath])
} else {
setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath))
}
setVaultPath(currentPath => {
if (currentPath !== removedPath) {
return currentPath
}
const remainingVaults = listRemainingVaults({
defaultVaults,
extraVaults,
hiddenDefaults,
isDefault,
removedPath,
})
}, [])
if (remainingVaults.length === 0) {
return currentPath
}
const switchVault = useCallback((path: string) => {
trackEvent('vault_switched')
setVaultPath(path)
onSwitchRef.current()
}, [])
return remainingVaults[0].path
})
}
function getRemovedVaultLabel(
path: string,
defaultVaults: VaultOption[],
extraVaults: VaultOption[],
): string {
const removedVault = [...defaultVaults, ...extraVaults].find(vault => vault.path === path)
return removedVault?.label ?? labelFromPath(path)
}
function useSwitchVaultAction(
onSwitchRef: MutableRefObject<() => void>,
setVaultPath: Dispatch<SetStateAction<string>>,
) {
return useCallback((path: string) => {
switchVaultPath(setVaultPath, onSwitchRef, path)
}, [onSwitchRef, setVaultPath])
}
function useVaultClonedAction(
addAndSwitch: (path: string, label: string) => void,
onToastRef: MutableRefObject<(msg: string) => void>,
) {
return useCallback((path: string, label: string) => {
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" cloned and opened`)
}, [addAndSwitch, onToastRef])
}
function useOpenLocalFolderAction(
addAndSwitch: (path: string, label: string) => void,
onToastRef: MutableRefObject<(msg: string) => void>,
) {
return useCallback(async () => {
const path = await pickFolder('Open vault folder')
if (!path) return
const label = labelFromPath(path)
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" opened`)
}, [addAndSwitch, onToastRef])
}
function useRemoveVaultAction({
defaultVaults,
extraVaults,
hiddenDefaults,
onSwitchRef,
onToastRef,
setExtraVaults,
setHiddenDefaults,
setVaultPath,
}: RemoveVaultActionOptions) {
return useCallback((path: string) => {
const isDefault = defaultVaults.some(vault => vault.path === path)
removeVaultFromState({
defaultVaults,
extraVaults,
hiddenDefaults,
isDefault,
onSwitchRef,
removedPath: path,
setExtraVaults,
setHiddenDefaults,
setVaultPath,
})
onToastRef.current(`Vault "${getRemovedVaultLabel(path, defaultVaults, extraVaults)}" removed from list`)
}, [
defaultVaults,
extraVaults,
hiddenDefaults,
onSwitchRef,
onToastRef,
setExtraVaults,
setHiddenDefaults,
setVaultPath,
])
}
function useRestoreGettingStartedAction(options: RestoreGettingStartedOptions) {
const { defaultPath, onToastRef, setHiddenDefaults, switchVault } = options
return useCallback(() => {
return restoreGettingStartedVault({
defaultPath,
onToastRef,
setHiddenDefaults,
switchVault,
})
}, [defaultPath, onToastRef, setHiddenDefaults, switchVault])
}
function useVaultActions({
defaultPath,
defaultVaults,
extraVaults,
hiddenDefaults,
onSwitchRef,
onToastRef,
setExtraVaults,
setHiddenDefaults,
setVaultPath,
}: VaultActionOptions) {
const addVault = useCallback((path: string, label: string) => {
addVaultToList(setExtraVaults, path, label)
}, [setExtraVaults])
const switchVault = useSwitchVaultAction(onSwitchRef, setVaultPath)
const addAndSwitch = useCallback((path: string, label: string) => {
addVault(path, label)
switchVault(path)
}, [addVault, switchVault])
const handleVaultCloned = useCallback((path: string, label: string) => {
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" cloned and opened`)
}, [addAndSwitch])
return {
handleOpenLocalFolder: useOpenLocalFolderAction(addAndSwitch, onToastRef),
handleVaultCloned: useVaultClonedAction(addAndSwitch, onToastRef),
removeVault: useRemoveVaultAction({
defaultVaults,
extraVaults,
hiddenDefaults,
onSwitchRef,
onToastRef,
setExtraVaults,
setHiddenDefaults,
setVaultPath,
}),
restoreGettingStarted: useRestoreGettingStartedAction({
defaultPath,
onToastRef,
setHiddenDefaults,
switchVault,
}),
switchVault,
}
}
const handleOpenLocalFolder = useCallback(async () => {
const path = await pickFolder('Open vault folder')
if (!path) return
const label = labelFromPath(path)
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" opened`)
}, [addAndSwitch])
async function restoreGettingStartedVault({
defaultPath,
onToastRef,
setHiddenDefaults,
switchVault,
}: RestoreGettingStartedOptions) {
if (!defaultPath) {
onToastRef.current('Could not resolve the Getting Started vault path')
return
}
const removeVault = useCallback((path: string) => {
const isDefault = defaultVaults.some(v => v.path === path)
if (isDefault) {
setHiddenDefaults(prev => prev.includes(path) ? prev : [...prev, path])
} else {
setExtraVaults(prev => prev.filter(v => v.path !== path))
}
try {
await ensureGettingStartedVaultReady(defaultPath)
setHiddenDefaults(previousHidden => previousHidden.filter(path => path !== defaultPath))
switchVault(defaultPath)
onToastRef.current('Getting Started vault ready')
} catch (err) {
onToastRef.current(formatGettingStartedRestoreError(err))
}
}
// If removing the active vault, switch to the first remaining vault
setVaultPath(currentPath => {
if (currentPath !== path) return currentPath
const remaining = [
...defaultVaults.filter(v => v.path !== path && !(isDefault ? [] : hiddenDefaults).includes(v.path)),
...extraVaults.filter(v => v.path !== path),
]
if (remaining.length > 0) {
onSwitchRef.current()
return remaining[0].path
}
return currentPath
})
/** Manages vault path, extra vaults, switching, cloning, and local folder opening.
* Vault list and active vault are persisted via Tauri backend to survive app updates. */
export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) {
const onSwitchRef = useRef(onSwitch)
const onToastRef = useRef(onToast)
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
const vault = [...defaultVaults, ...extraVaults].find(v => v.path === path)
onToastRef.current(`Vault "${vault?.label ?? labelFromPath(path)}" removed from list`)
}, [defaultVaults, extraVaults, hiddenDefaults])
const restoreGettingStarted = useCallback(async () => {
const gsPath = defaultPath
// Un-hide the Getting Started vault
setHiddenDefaults(prev => prev.filter(p => p !== gsPath))
// Try to create the vault if it doesn't exist on disk
try {
const exists = await tauriCall<boolean>('check_vault_exists', { path: gsPath })
if (!exists) {
await tauriCall<string>('create_getting_started_vault', { targetPath: gsPath })
}
} catch {
// In mock/test mode, creation may fail — that's fine
}
switchVault(gsPath)
onToastRef.current('Getting Started vault restored')
}, [defaultPath, switchVault])
const persistedState = usePersistedVaultState(onSwitchRef)
const { defaultPath, extraVaults, hiddenDefaults, loaded, vaultPath } = persistedState
const { allVaults, defaultVaults, isGettingStartedHidden } = useVaultCollections(
defaultPath,
hiddenDefaults,
extraVaults,
)
const { handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
...persistedState,
allVaults,
defaultVaults,
isGettingStartedHidden,
onSwitchRef,
onToastRef,
})
return {
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,

View File

@@ -14,18 +14,6 @@ import {
type AppCommandShortcutEventOptions,
} from './hooks/appCommandCatalog'
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
}
}
}
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
// at native level before React's synthetic events can call preventDefault).
// Capture phase fires first → prevents native menu; React bubble phase still fires

View File

@@ -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 }

View File

@@ -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

View File

@@ -0,0 +1,21 @@
import type {
AppCommandShortcutEventInit,
AppCommandShortcutEventOptions,
} from '../hooks/appCommandCatalog'
export interface LaputaTestBridge {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
seedBlockNoteTable?: (columnWidths?: Array<number | null>) => Promise<void> | void
}
declare global {
interface Window {
__laputaTest?: LaputaTestBridge
}
}
export {}

View 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))
}

View File

@@ -7,6 +7,7 @@ const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
const FIXTURE_VAULT_READY_TIMEOUT = 30_000
const FIXTURE_VAULT_REMOVE_RETRIES = 10
const FIXTURE_VAULT_REMOVE_RETRY_DELAY_MS = 100
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
function copyDirSync(src: string, dest: string): void {
fs.mkdirSync(dest, { recursive: true })
@@ -41,8 +42,9 @@ export async function openFixtureVault(
page: Page,
vaultPath: string,
): Promise<void> {
await page.addInitScript((resolvedVaultPath: string) => {
await page.addInitScript(({ dismissedKey, resolvedVaultPath }: { dismissedKey: string; resolvedVaultPath: string }) => {
localStorage.clear()
localStorage.setItem(dismissedKey, '1')
const nativeFetch = window.fetch.bind(window)
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
@@ -93,7 +95,7 @@ export async function openFixtureVault(
return applyFixtureVaultOverrides(ref) ?? ref
},
})
}, vaultPath)
}, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath })
await page.goto('/', { waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => Boolean(window.__mockHandlers))
@@ -261,126 +263,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__', {

View File

@@ -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()

View File

@@ -51,5 +51,8 @@ test('Getting Started template shows inline retry on clone failure and opens aft
await page.getByTestId('welcome-retry-template').click()
await expect(page.getByTestId('welcome-screen')).not.toBeVisible()
await expect(page.getByTestId('claude-onboarding-screen')).toBeVisible()
await expect(page.getByText('Claude Code not detected')).toBeVisible()
await page.getByTestId('claude-onboarding-continue').click()
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible()
})

View File

@@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test'
import { executeCommand, openCommandPalette } from './helpers'
test('commit flow stays local when the active vault has no remote @smoke', async ({ page }) => {
await page.addInitScript(() => {
type Handler = (args?: Record<string, unknown>) => unknown
type BrowserWindow = Window & typeof globalThis & {
__gitPushCalls?: number
__mockHandlers?: Record<string, Handler>
__mockHandlersRef?: Record<string, Handler> | null
}
const browserWindow = window as BrowserWindow
const applyOverrides = (handlers?: Record<string, Handler> | null) => {
if (!handlers) return handlers ?? null
handlers.git_remote_status = () => ({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
handlers.git_push = () => {
browserWindow.__gitPushCalls = (browserWindow.__gitPushCalls ?? 0) + 1
return { status: 'ok', message: 'Pushed to remote' }
}
return handlers
}
browserWindow.__gitPushCalls = 0
let ref = applyOverrides(browserWindow.__mockHandlers) ?? null
Object.defineProperty(browserWindow, '__mockHandlers', {
configurable: true,
set(value) {
ref = applyOverrides(value as Record<string, Handler> | undefined) ?? null
},
get() {
return applyOverrides(ref) ?? ref
},
})
})
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('status-no-remote')).toContainText('No remote')
await openCommandPalette(page)
await executeCommand(page, 'Commit & Push')
await expect(page.getByRole('heading', { name: 'Commit' })).toBeVisible()
await expect(page.getByText(/local commit only/i)).toBeVisible()
await page.locator('textarea[placeholder="Commit message..."]').fill('test local commit')
await page.getByRole('button', { name: 'Commit' }).click()
await expect(page.locator('.fixed.bottom-8')).toContainText('Committed locally', { timeout: 5000 })
await expect.poll(async () =>
page.evaluate(() => (window as Window & { __gitPushCalls?: number }).__gitPushCalls ?? 0),
).toBe(0)
})

View File

@@ -0,0 +1,64 @@
import { test, expect, type Page } from '@playwright/test'
async function installOnboardingMocks(page: Page, offline: boolean) {
await page.addInitScript((isOffline: boolean) => {
localStorage.clear()
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
ref.load_vault_list = () => ({
vaults: [],
active_vault: null,
hidden_defaults: [],
})
ref.get_default_vault_path = () => '/Users/mock/Documents/Getting Started'
ref.check_vault_exists = () => false
ref.create_getting_started_vault = (args: { targetPath?: string | null }) => {
return args.targetPath || '/Users/mock/Documents/Getting Started'
}
},
get() {
return ref
},
})
Object.defineProperty(window, 'prompt', {
configurable: true,
value: () => '/Users/mock/Documents/Getting Started',
})
Object.defineProperty(window.navigator, 'onLine', {
configurable: true,
get: () => !isOffline,
})
}, offline)
}
test('offline onboarding disables template cloning and explains clone-later behavior', async ({ page }) => {
await installOnboardingMocks(page, true)
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toBeEnabled()
await expect(page.getByTestId('welcome-open-folder')).toBeEnabled()
await expect(page.getByTestId('welcome-create-vault')).toBeDisabled()
await expect(page.getByText('Requires internet — clone later. Suggested path: /Users/mock/Documents/Getting Started')).toBeVisible()
})
test('status bar keeps a Getting Started clone entry available after onboarding', async ({ page }) => {
await installOnboardingMocks(page, false)
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await page.getByTestId('welcome-create-vault').click()
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible()
await page.getByTitle('Switch vault').click()
await expect(page.getByTestId('vault-menu-clone-getting-started')).toBeVisible()
})

View File

@@ -1,6 +1,6 @@
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand } from './testBridge'
import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
let tempVaultDir: string
@@ -24,46 +24,15 @@ function trackUnexpectedErrors(page: Page): string[] {
async function createUntitledNote(page: Page): Promise<void> {
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}), {
timeout: 5_000,
}).toBe(true)
}
async function insertTable(page: Page): Promise<void> {
const editorSurface = page.locator('.bn-editor')
await expect(editorSurface).toBeVisible({ timeout: 5_000 })
await editorSurface.click()
await page.keyboard.type('/table')
const tableCommand = page.getByText('Table with editable cells', { exact: true })
await expect(tableCommand).toBeVisible({ timeout: 5_000 })
await tableCommand.click()
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
}
async function dragFirstColumn(page: Page, deltaX: number): Promise<void> {
const firstCell = page.locator('table td, table th').first()
await expect(firstCell).toBeVisible({ timeout: 5_000 })
const box = await firstCell.boundingBox()
if (!box) throw new Error('Could not measure first table cell')
const handleX = box.x + box.width - 1
const handleY = box.y + (box.height / 2)
await page.mouse.move(handleX, handleY)
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
await page.mouse.down()
await page.mouse.move(handleX + deltaX, handleY, { steps: 8 })
await page.mouse.up()
async function seedResizedTable(page: Page): Promise<void> {
await seedBlockNoteTable(page, [180, 120, 120])
}
test.describe('table resize crash fix', () => {
test.beforeEach((_, testInfo) => {
test.beforeEach(({ page }, testInfo) => {
void page
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
})
@@ -72,50 +41,20 @@ test.describe('table resize crash fix', () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('resizing a table column keeps the editor stable and changes the cell width', async ({ page }) => {
test('switching to raw mode and back after a seeded resized table does not lose the table', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await insertTable(page)
await seedResizedTable(page)
const firstCell = page.locator('table td, table th').first()
const before = await firstCell.boundingBox()
if (!before) throw new Error('Could not measure first table cell before resize')
await dragFirstColumn(page, 80)
const after = await firstCell.boundingBox()
if (!after) throw new Error('Could not measure first table cell after resize')
expect(after.width).toBeGreaterThan(before.width)
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
test('unmounting the editor mid-resize does not surface stale ProseMirror view errors', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await insertTable(page)
const firstCell = page.locator('table td, table th').first()
await expect(firstCell).toBeVisible({ timeout: 5_000 })
const box = await firstCell.boundingBox()
if (!box) throw new Error('Could not measure first table cell before resize')
const handleX = box.x + box.width - 1
const handleY = box.y + (box.height / 2)
await page.mouse.move(handleX, handleY)
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
await page.mouse.down()
await page.keyboard.press('Meta+Backslash')
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await page.mouse.move(handleX + 80, handleY, { steps: 8 })
await page.mouse.up()
await expect(page.locator('.cm-content')).toContainText('| Head 1 | Head 2 | Head 3 |')
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
expect(errors).toEqual([])
})

View File

@@ -38,6 +38,19 @@ export async function triggerMenuCommand(page: Page, id: string): Promise<void>
}, id)
}
export async function seedBlockNoteTable(
page: Page,
columnWidths?: Array<number | null>,
): Promise<void> {
await page.evaluate((widths) => {
const bridge = window.__laputaTest?.seedBlockNoteTable
if (typeof bridge !== 'function') {
throw new Error('Tolaria test bridge is missing seedBlockNoteTable')
}
return bridge(widths ?? undefined)
}, columnWidths)
}
export async function dispatchShortcutEvent(
page: Page,
init: AppCommandShortcutEventInit,