Compare commits
13 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2745d96eb | ||
|
|
a2c84d3ce8 | ||
|
|
afbea1367c | ||
|
|
7933fe1bfb | ||
|
|
d16aa29d18 | ||
|
|
6188395141 | ||
|
|
1bf5a7d6e4 | ||
|
|
27e371c7fe | ||
|
|
867e97b199 | ||
|
|
2441518cee | ||
|
|
010bc32ee1 | ||
|
|
361898b187 | ||
|
|
5b5f949c74 |
15
.github/workflows/release-canary.yml
vendored
15
.github/workflows/release-canary.yml
vendored
@@ -172,7 +172,9 @@ jobs:
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
TAG="${{ needs.version.outputs.tag }}"
|
||||
REPO="refactoringhq/laputa-app"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
@@ -180,7 +182,7 @@ jobs:
|
||||
cat > latest-canary.json << EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "Canary build. See https://refactoringhq.github.io/laputa-app/ for release notes.",
|
||||
"notes": "Canary build. See ${PAGES_URL} for release notes.",
|
||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
@@ -196,7 +198,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Laputa ${{ needs.version.outputs.version }} (Canary)
|
||||
name: Tolaria ${{ needs.version.outputs.version }} (Canary)
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: true
|
||||
@@ -227,8 +229,9 @@ jobs:
|
||||
run: |
|
||||
mkdir -p _site
|
||||
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
# Download stable latest.json from existing GH Pages (preserve it)
|
||||
curl -fsSL "https://refactoringhq.github.io/laputa-app/latest.json" -o _site/latest.json || echo '{}' > _site/latest.json
|
||||
curl -fsSL "${PAGES_URL}/latest.json" -o _site/latest.json || echo '{}' > _site/latest.json
|
||||
# Copy canary latest.json from this release
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "latest-canary.json" --output _site/latest-canary.json || true
|
||||
cat > _site/index.html << 'HTMLEOF'
|
||||
@@ -237,7 +240,7 @@ jobs:
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Laputa — Release History</title>
|
||||
<title>Tolaria — Release History</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
|
||||
@@ -255,7 +258,7 @@ jobs:
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Laputa Release History</h1>
|
||||
<h1>Tolaria Release History</h1>
|
||||
<p class="subtitle">Auto-updated on every release</p>
|
||||
<div id="releases"></div>
|
||||
<script>
|
||||
|
||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -173,7 +173,9 @@ jobs:
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
TAG="${{ needs.version.outputs.tag }}"
|
||||
REPO="refactoringhq/laputa-app"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
@@ -181,7 +183,7 @@ jobs:
|
||||
cat > latest.json << EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "See https://refactoringhq.github.io/laputa-app/ for full release notes.",
|
||||
"notes": "See ${PAGES_URL} for full release notes.",
|
||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
@@ -197,7 +199,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Laputa ${{ needs.version.outputs.version }}
|
||||
name: Tolaria ${{ needs.version.outputs.version }}
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -228,17 +230,18 @@ jobs:
|
||||
run: |
|
||||
mkdir -p _site
|
||||
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
# Copy latest.json to GitHub Pages for auto-updater endpoint
|
||||
gh release download --repo ${{ github.repository }} --pattern "latest.json" --output _site/latest.json || true
|
||||
# Preserve canary latest.json from existing GH Pages
|
||||
curl -fsSL "https://refactoringhq.github.io/laputa-app/latest-canary.json" -o _site/latest-canary.json || true
|
||||
curl -fsSL "${PAGES_URL}/latest-canary.json" -o _site/latest-canary.json || true
|
||||
cat > _site/index.html << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Laputa — Release History</title>
|
||||
<title>Tolaria — Release History</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
|
||||
@@ -255,7 +258,7 @@ jobs:
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Laputa Release History</h1>
|
||||
<h1>Tolaria Release History</h1>
|
||||
<p class="subtitle">Auto-updated on every release</p>
|
||||
<div id="releases"></div>
|
||||
<script>
|
||||
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -1,4 +1,4 @@
|
||||
# AGENTS.md — Laputa App
|
||||
# AGENTS.md — Tolaria App
|
||||
|
||||
> Quick links: [Project Spec](docs/PROJECT-SPEC.md) · [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -130,7 +133,7 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
|
||||
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
|
||||
| Dialog/modal | `Dialog` from shadcn/ui |
|
||||
|
||||
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Laputa — if it looks like a browser default, it's wrong.
|
||||
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Tolaria — if it looks like a browser default, it's wrong.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Laputa App
|
||||
# Tolaria App
|
||||
|
||||
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Abstractions
|
||||
|
||||
Key abstractions and domain models in Laputa.
|
||||
Key abstractions and domain models in Tolaria.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Laputa's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
|
||||
Tolaria's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
|
||||
|
||||
The full set of design principles is documented in [ARCHITECTURE.md](./ARCHITECTURE.md#design-principles).
|
||||
|
||||
## Semantic Field Names (conventions)
|
||||
|
||||
These frontmatter field names have special meaning in Laputa's UI:
|
||||
These frontmatter field names have special meaning in Tolaria's UI:
|
||||
|
||||
| Field | Meaning | UI behavior |
|
||||
|---|---|---|
|
||||
@@ -35,7 +35,7 @@ Any frontmatter field whose name starts with `_` is a **system property**:
|
||||
- It is **not shown** in the Properties panel (neither for notes nor for Type notes)
|
||||
- It is **not exposed** as a user-visible property in search, filters, or the UI
|
||||
- It **is editable** directly in the raw editor (power users can access it if needed)
|
||||
- It is used by Laputa internally for configuration, behavior, and UI preferences
|
||||
- It is used by Tolaria internally for configuration, behavior, and UI preferences
|
||||
|
||||
Examples:
|
||||
```yaml
|
||||
@@ -233,7 +233,7 @@ All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex an
|
||||
|
||||
### Title / Filename Sync
|
||||
|
||||
Laputa separates **display title** from the file identifier:
|
||||
Tolaria separates **display title** from the file identifier:
|
||||
|
||||
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
|
||||
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
|
||||
@@ -495,7 +495,7 @@ No indexing step required — search runs directly against the filesystem.
|
||||
### Vault Switching
|
||||
|
||||
`useVaultSwitcher` hook manages multiple vaults:
|
||||
- Persists vault list to `~/.config/com.laputa.app/vaults.json`
|
||||
- Persists vault list to `~/.config/com.tolaria.app/vaults.json` (reads legacy `com.laputa.app` on upgrade)
|
||||
- Switching closes all tabs and resets sidebar
|
||||
- Supports adding, removing, hiding/restoring vaults
|
||||
- Default vault: Getting Started demo vault
|
||||
@@ -512,27 +512,28 @@ Per-vault settings stored locally and scoped by vault path:
|
||||
`useOnboarding` hook detects first launch:
|
||||
- If vault path doesn't exist → show `WelcomeScreen`
|
||||
- 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 (`laputa_welcome_dismissed`)
|
||||
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
|
||||
|
||||
### GitHub Integration
|
||||
### Remote Git Operations
|
||||
|
||||
Device Authorization Flow for GitHub-backed vaults:
|
||||
- `GitHubDeviceFlow` component handles OAuth
|
||||
- `GitHubVaultModal` for cloning existing repos or creating new ones
|
||||
- Token persisted in app settings for future git operations
|
||||
- `SettingsPanel` shows connection status with disconnect option
|
||||
Tolaria delegates remote auth to the user's system git setup:
|
||||
- `CloneVaultModal` captures a remote URL and local destination
|
||||
- `clone_repo` shells out to system git for clone operations
|
||||
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors
|
||||
- No provider-specific token or username is stored in app settings
|
||||
|
||||
## Settings
|
||||
|
||||
App-level settings persisted at `~/.config/com.laputa.app/settings.json`:
|
||||
App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads legacy `com.laputa.app` on upgrade):
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
Laputa is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
|
||||
Tolaria is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
|
||||
|
||||
## Design Principles
|
||||
|
||||
@@ -10,23 +10,23 @@ The vault is a folder of plain markdown files. The app never owns the data — i
|
||||
|
||||
### Convention over configuration
|
||||
|
||||
Laputa is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `Belongs to:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
|
||||
Tolaria is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `Belongs to:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
|
||||
|
||||
This principle directly serves AI-readability: the more structure comes from shared conventions rather than per-user custom configurations, the easier it is for an AI agent to understand and navigate the vault correctly — without needing bespoke instructions for every setup.
|
||||
|
||||
### Where to store state: vault vs. app settings
|
||||
|
||||
When deciding where to persist a piece of data, ask: **"Would the user want this to follow them across all their Laputa installations — other devices, future platforms (tablet, web)?"**
|
||||
When deciding where to persist a piece of data, ask: **"Would the user want this to follow them across all their Tolaria installations — other devices, future platforms (tablet, web)?"**
|
||||
|
||||
| Follows the vault | Stays with the installation |
|
||||
|-------------------|-----------------------------|
|
||||
| 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 |
|
||||
|
||||
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.laputa.app/settings.json` or localStorage.
|
||||
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
|
||||
|
||||
Examples:
|
||||
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
|
||||
@@ -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
|
||||
@@ -265,7 +268,7 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri
|
||||
|
||||
### Authentication
|
||||
|
||||
Claude CLI (agent mode) uses its own authentication — no API key configuration needed in Laputa.
|
||||
Claude CLI (agent mode) uses its own authentication — no API key configuration needed in Tolaria.
|
||||
|
||||
## MCP Server
|
||||
|
||||
@@ -285,7 +288,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
|
||||
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
|
||||
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
|
||||
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
|
||||
| `ui_open_note` | `path` | Open a note in the Tolaria UI editor |
|
||||
| `ui_open_tab` | `path` | Open a note in a new UI tab |
|
||||
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
|
||||
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
|
||||
@@ -293,13 +296,13 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
### Transports
|
||||
|
||||
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
|
||||
- **WebSocket** — live bridge for Laputa app integration:
|
||||
- **WebSocket** — live bridge for Tolaria app integration:
|
||||
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
|
||||
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
|
||||
|
||||
### Auto-Registration
|
||||
|
||||
On app startup, Laputa automatically registers itself as an MCP server in:
|
||||
On app startup, Tolaria automatically registers itself as an MCP server in:
|
||||
- `~/.claude/mcp.json` (Claude Code)
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
|
||||
@@ -349,7 +352,7 @@ flowchart LR
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
|
||||
| `register_mcp(vault_path)` | Writes Laputa entry to Claude Code and Cursor MCP configs |
|
||||
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
|
||||
@@ -400,7 +403,7 @@ The app uses a single light theme with no user-configurable theming (see [ADR-00
|
||||
|
||||
### Vault List
|
||||
|
||||
Persisted at `~/.config/com.laputa.app/vaults.json`:
|
||||
Persisted at `~/.config/com.tolaria.app/vaults.json` (reads legacy `com.laputa.app` on upgrade):
|
||||
```json
|
||||
{
|
||||
"vaults": [{ "label": "My Vault", "path": "/path/to/vault" }],
|
||||
@@ -429,23 +432,23 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
|
||||
- **Open an existing folder** → system file picker
|
||||
- **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime
|
||||
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public GitHub URL and delegates the actual clone to the existing git backend.
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public starter repo URL and delegates the actual clone to the git backend.
|
||||
|
||||
### GitHub OAuth Integration
|
||||
### Remote Clone & Auth Model
|
||||
|
||||
Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed vaults.
|
||||
Tolaria no longer implements provider-specific OAuth or remote-repository APIs. All remote git work goes through the user's existing system git configuration.
|
||||
|
||||
**Flow:**
|
||||
1. User clicks "Login with GitHub" in Settings panel
|
||||
2. `github_device_flow_start()` returns a user code + verification URL
|
||||
3. User authorizes at `github.com/login/device`
|
||||
4. App polls `github_device_flow_poll()` until authorized
|
||||
5. Token stored in `~/.config/com.laputa.app/settings.json`
|
||||
1. User opens `CloneVaultModal` from onboarding or the vault menu
|
||||
2. User pastes any git URL and chooses a local destination
|
||||
3. `clone_repo()` shells out to `git clone`
|
||||
4. `git_push()` / `git_pull()` continue to use the same system git path
|
||||
5. If auth fails, the raw git stderr is surfaced in the UI
|
||||
|
||||
**Vault operations:**
|
||||
- `GitHubVaultModal`: Clone existing repo or create new private/public repo
|
||||
- `clone_repo()`: Clones with token-injected HTTPS URL
|
||||
- Token persists for future git push/pull operations
|
||||
**Auth model:**
|
||||
- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup
|
||||
- No provider tokens are stored in Tolaria settings
|
||||
- The same flow works for GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes
|
||||
|
||||
## Pulse View
|
||||
|
||||
@@ -565,8 +568,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|--------|---------|
|
||||
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
|
||||
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`) |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan |
|
||||
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
|
||||
| `mcp.rs` | MCP server spawning + config registration |
|
||||
@@ -576,7 +578,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
|
||||
## Tauri IPC Commands (65 total)
|
||||
## Tauri IPC Commands
|
||||
|
||||
### Vault Operations
|
||||
|
||||
@@ -620,17 +622,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `get_conflict_mode` | Get conflict resolution mode |
|
||||
| `get_vault_pulse` | Git activity feed (paginated) |
|
||||
| `get_last_commit_info` | Latest commit metadata |
|
||||
|
||||
### GitHub
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `github_device_flow_start` | Begin OAuth device flow |
|
||||
| `github_device_flow_poll` | Poll for authorization |
|
||||
| `github_get_user` | Get authenticated user info |
|
||||
| `github_list_repos` | List user's repos |
|
||||
| `github_create_repo` | Create new repo |
|
||||
| `clone_repo` | Clone repo with token auth |
|
||||
| `clone_repo` | Clone a remote repository into a local folder using system git |
|
||||
|
||||
### Search
|
||||
|
||||
@@ -705,7 +697,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (API keys, GitHub token) | Persistent settings |
|
||||
| `useSettings` | App settings (telemetry, release channel, auto-sync interval) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
@@ -821,7 +813,7 @@ sequenceDiagram
|
||||
|
||||
### Updates
|
||||
|
||||
Laputa uses the Tauri updater plugin for automatic updates:
|
||||
Tolaria uses the Tauri updater plugin for automatic updates:
|
||||
|
||||
- Builds from `main` branch are published as GitHub Releases
|
||||
- `latest.json` is published to GitHub Pages for the updater plugin
|
||||
@@ -868,7 +860,7 @@ Desktop-only modules gated at the crate level:
|
||||
|
||||
Desktop-only features gated at the function level in `commands/`:
|
||||
- Git operations (commit, pull, push, status, history, diff, conflicts)
|
||||
- GitHub operations (clone, list repos, device flow auth)
|
||||
- Clone-by-URL via system git (`clone_repo`)
|
||||
- Claude CLI streaming (check, chat, agent)
|
||||
- MCP registration and status
|
||||
- Menu state updates
|
||||
|
||||
@@ -31,7 +31,7 @@ pnpm playwright:regression # Full Playwright regression suite
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
laputa-app/
|
||||
tolaria/
|
||||
├── src/ # React frontend
|
||||
│ ├── main.tsx # Entry point (renders <App />)
|
||||
│ ├── App.tsx # Root component — orchestrates layout + state
|
||||
@@ -63,8 +63,7 @@ laputa-app/
|
||||
│ │ ├── 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 @@ laputa-app/
|
||||
│ ├── 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 @@ laputa-app/
|
||||
│ │ ├── 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 @@ laputa-app/
|
||||
| `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 @@ laputa-app/
|
||||
|
||||
| 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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Laputa — Product Vision
|
||||
# Tolaria — Product Vision
|
||||
|
||||
*Written by Brian based on conversations with Luca Rossi, Feb–Mar 2026.*
|
||||
*This is a living document — update it as the vision evolves.*
|
||||
@@ -13,21 +13,21 @@ The best projects are built by people who have an unusually strong answer to "wh
|
||||
|
||||
**Luca Rossi** is a startup founder and former generalist CTO — someone who can build a product end-to-end across code, design, scope, and product. And for the last five years, full-time, he has run Refactoring: a technical newsletter with nearly 200,000 subscribers, for which he has written over 300 original articles. In word count, that's roughly two *Lord of the Rings* novels.
|
||||
|
||||
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Laputa is an attempt to bottle that system.
|
||||
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Tolaria is an attempt to bottle that system.
|
||||
|
||||
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Laputa is not theorized — it's been battle-tested for years at scale.
|
||||
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Tolaria is not theorized — it's been battle-tested for years at scale.
|
||||
|
||||
**The distribution is built in.** Refactoring reaches ~200,000 engineers, managers, and technical leaders — exactly the people most receptive to a tool like this. The audience already trusts the author on this topic, because they've been reading his writing about knowledge management and learning for years.
|
||||
|
||||
This is not a product looking for a market. It's a tool built by its first power user, for an audience that already knows and trusts him.
|
||||
|
||||
**Why Laputa, in the context of Refactoring.**
|
||||
**Why Tolaria, in the context of Refactoring.**
|
||||
|
||||
Refactoring is a newsletter about how software is built, how teams work, and how digital products are developed — written from Luca's experience and conversations with other tech leaders. A natural question follows: what is the author's own current experience building software with AI?
|
||||
|
||||
Laputa answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
|
||||
Tolaria answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
|
||||
|
||||
This is why Laputa is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
|
||||
This is why Tolaria is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
|
||||
|
||||
The strategy is coherent: build the tool you describe, make the work visible, let the product speak for the author.
|
||||
|
||||
@@ -45,7 +45,7 @@ The problem has two distinct layers:
|
||||
|
||||
2. **Methodological**: even with the right tool, most people don't know *how* to organize knowledge so it becomes useful over time — what to capture, how to connect things, how to turn raw notes into a system that works with you instead of against you.
|
||||
|
||||
Laputa addresses both layers, together. That's what makes it different.
|
||||
Tolaria addresses both layers, together. That's what makes it different.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,11 +53,11 @@ Laputa addresses both layers, together. That's what makes it different.
|
||||
|
||||
Most PKM tools give you a blank canvas and leave the rest to you. They solve the first problem (somewhere to put things) but not the second (how to organize them). The result is that sophisticated users build complex custom systems, while everyone else gives up.
|
||||
|
||||
Laputa's position is different: **we ship the method alongside the tool.**
|
||||
Tolaria's position is different: **we ship the method alongside the tool.**
|
||||
|
||||
The method is opinionated but not rigid. It tells you: here's how to think about your work, here's where different kinds of notes belong, here's how to connect them. If it fits your needs — great, start immediately. If your situation is different — customize it. The types, the relationships, the structure can all be changed. But you don't have to figure it out from scratch.
|
||||
|
||||
This combination — an opinionated method on top of a technically excellent foundation — is what makes Laputa genuinely useful to people who are stuck, not just people who already know what they're doing.
|
||||
This combination — an opinionated method on top of a technically excellent foundation — is what makes Tolaria genuinely useful to people who are stuck, not just people who already know what they're doing.
|
||||
|
||||
---
|
||||
|
||||
@@ -65,7 +65,7 @@ This combination — an opinionated method on top of a technically excellent fou
|
||||
|
||||
### The knowledge ontology
|
||||
|
||||
Laputa organizes work around two axes:
|
||||
Tolaria organizes work around two axes:
|
||||
|
||||
| | **One-time** | **Recurring** |
|
||||
|---|---|---|
|
||||
@@ -84,7 +84,7 @@ This ontology is not arbitrary. It maps cleanly to how both individuals and orga
|
||||
|
||||
### Knowledge has a purpose
|
||||
|
||||
A principle that underlies everything in Laputa: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
|
||||
A principle that underlies everything in Tolaria: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
|
||||
|
||||
This is the difference between a knowledge system that works over years and one that collapses after a few weeks. Without a real purpose, the maintenance cost of taking notes is never justified, and people stop. With a purpose — writing regularly, building things, making decisions — the system pays for itself.
|
||||
|
||||
@@ -120,7 +120,7 @@ This is convention *over* configuration — not convention *instead of* it.
|
||||
|
||||
## The foundation: architecture that earns trust
|
||||
|
||||
The method is only as good as the system it runs on. Laputa's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
|
||||
The method is only as good as the system it runs on. Tolaria's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
|
||||
|
||||
### Local files, version-controlled with Git
|
||||
|
||||
@@ -134,11 +134,11 @@ A vault of plain Markdown files, version-controlled with Git, is dramatically mo
|
||||
|
||||
An AI agent working on a local vault can read thousands of notes in seconds, understand their structure, write new ones, connect existing ones, and commit the changes back — all with full comprehension. Notion's AI can't do this. No SaaS-based AI can do this, because the architecture doesn't allow it.
|
||||
|
||||
More importantly: the more a vault follows Laputa's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
|
||||
More importantly: the more a vault follows Tolaria's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
|
||||
|
||||
### Open and exit-friendly
|
||||
|
||||
The trust between Laputa and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
|
||||
The trust between Tolaria and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,9 +147,9 @@ The trust between Laputa and the user is earned daily, not enforced by format. I
|
||||
Obsidian is the obvious comparison. The difference is philosophy:
|
||||
|
||||
- **Obsidian** is a blank canvas. Infinitely configurable via plugins and community extensions. Powerful for users who want to build their own system — and who have the time and patience to do so.
|
||||
- **Laputa** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
|
||||
- **Tolaria** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
|
||||
|
||||
Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Laputa, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
|
||||
Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Tolaria, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
|
||||
|
||||
---
|
||||
|
||||
@@ -157,7 +157,7 @@ Obsidian also treats Git as an afterthought — its business model is built arou
|
||||
|
||||
### Three stages of adoption
|
||||
|
||||
Laputa is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
|
||||
Tolaria is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
|
||||
|
||||
**Stage 1: Personal PKM + AI context** *(current)*
|
||||
A single person manages their knowledge, life, and work in a local vault. The primary collaborator is AI. The vault gives structure to one person's context, making it legible to an AI that can assist meaningfully across all areas of work and life. The method helps structure the knowledge; the AI helps use it.
|
||||
@@ -172,7 +172,7 @@ The ontology scales to organizations. Companies have projects, responsibilities,
|
||||
|
||||
### The right early adopters
|
||||
|
||||
The first users who will get the most from Laputa are technically-minded individuals who:
|
||||
The first users who will get the most from Tolaria are technically-minded individuals who:
|
||||
- Are frustrated with Notion's performance, complexity, or lock-in
|
||||
- Understand or are comfortable with Git
|
||||
- Want a system that's AI-native by design, not by bolted-on features
|
||||
|
||||
50
docs/adr/0056-system-git-cli-auth-no-provider-oauth.md
Normal file
50
docs/adr/0056-system-git-cli-auth-no-provider-oauth.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0056"
|
||||
title: "System git auth only — no provider-specific OAuth or repo APIs"
|
||||
status: active
|
||||
date: 2026-04-12
|
||||
supersedes: "0019"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already uses the system `git` executable for the core remote workflow: commit, pull, push, status, history, and conflict resolution. The only provider-specific part left was GitHub authentication and repository management:
|
||||
|
||||
- GitHub Device Flow OAuth
|
||||
- persisted `github_token` / `github_username` settings
|
||||
- GitHub-only clone/create UI
|
||||
- GitHub API calls for repo listing and creation
|
||||
|
||||
That split made the product more complex than the actual user need. Tolaria's remote-sync users are developers who typically already have git configured via SSH keys, Git Credential Manager, Keychain helpers, or `gh auth`. The app was carrying a provider-specific auth stack even though the real transport path was already plain git CLI.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria does not implement provider-specific authentication or remote-repository APIs. All remote auth is delegated to the user's existing system git configuration, and cloning is a generic "paste any git URL" flow.**
|
||||
|
||||
Concretely:
|
||||
|
||||
- remove GitHub Device Flow commands and UI
|
||||
- remove persisted GitHub auth fields from app settings
|
||||
- remove GitHub repo list/create API integration
|
||||
- keep `clone_repo`, but make it a generic system-git clone command
|
||||
- keep commit / pull / push behavior unchanged apart from surfacing raw git errors directly
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A — Keep GitHub Device Flow OAuth** (ADR-0019, now superseded): polished GitHub-specific onboarding, but it preserves provider lock-in, token storage, and an entire second auth model beside system git.
|
||||
- **Option B — Replace OAuth with manual PAT entry**: smaller implementation than Device Flow, but still provider-specific, still stores credentials in app settings, and still teaches users the wrong abstraction.
|
||||
- **Option C — Pure system git auth** (chosen): one auth path, less code, works with any git host, and aligns the clone flow with the rest of Tolaria's git stack. Downside: users must already have git auth configured outside the app.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `CloneVaultModal` accepts any git URL and local destination path.
|
||||
- `clone_repo` shells out to system git without injecting provider tokens.
|
||||
- `git_push` / `git_pull` continue to rely on the same external git configuration; auth failures surface as raw git stderr.
|
||||
- `SettingsPanel` no longer contains a GitHub connection section.
|
||||
- Tolaria no longer stores git-provider credentials in `settings.json`.
|
||||
- GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes all work through the same product path.
|
||||
- Creating or listing remote repos from inside Tolaria is no longer supported; remote setup happens in the user's normal git tools.
|
||||
- The Getting Started vault still clones from a public remote URL, but it now goes through the same generic git clone path as every other vault import.
|
||||
|
||||
Re-evaluate if Tolaria later targets less technical users who cannot reasonably be expected to configure git outside the app.
|
||||
@@ -74,7 +74,7 @@ proposed → active → superseded
|
||||
| [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active |
|
||||
| [0017](0017-canary-release-channel.md) | Canary release channel and feature flags | active |
|
||||
| [0018](0018-codescene-code-health-gates.md) | CodeScene code health gates in CI | active |
|
||||
| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | active |
|
||||
| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | superseded → [0056](0056-system-git-cli-auth-no-provider-oauth.md) |
|
||||
| [0020](0020-keyboard-first-design.md) | Keyboard-first design principle | active |
|
||||
| [0021](0021-push-to-main-workflow.md) | Push directly to main (no PRs) | active |
|
||||
| [0022](0022-blocknote-rich-text-editor.md) | BlockNote as the rich text editor | active |
|
||||
@@ -111,3 +111,4 @@ proposed → active → superseded
|
||||
| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active |
|
||||
| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active |
|
||||
| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active |
|
||||
| [0056](0056-system-git-cli-auth-no-provider-oauth.md) | System git auth only — no provider-specific OAuth or repo APIs | active |
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
|
||||
<title>laputa-scaffold</title>
|
||||
<title>Tolaria</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Apply saved theme before React mounts to prevent flash
|
||||
var t = localStorage.getItem('laputa-theme');
|
||||
var t = localStorage.getItem('tolaria-theme');
|
||||
if (t === null) {
|
||||
t = localStorage.getItem('laputa-theme');
|
||||
if (t !== null) localStorage.setItem('tolaria-theme', t);
|
||||
}
|
||||
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Laputa MCP Server — lightweight vault tools for AI agents.
|
||||
* Tolaria MCP Server — lightweight vault tools for AI agents.
|
||||
*
|
||||
* The agent has full shell access (bash, read, write, edit).
|
||||
* These MCP tools provide Laputa-specific capabilities that
|
||||
* These MCP tools provide Tolaria-specific capabilities that
|
||||
* native tools cannot replace:
|
||||
*
|
||||
* - search_notes: full-text search across vault notes
|
||||
* - get_vault_context: vault structure overview (types, note count, folders)
|
||||
* - get_note: parsed frontmatter + content (convenience over raw cat)
|
||||
* - open_note: signal Laputa UI to open a note as a tab
|
||||
* - open_note: signal Tolaria UI to open a note as a tab
|
||||
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
|
||||
* - refresh_vault: trigger vault rescan so new/modified files appear
|
||||
*/
|
||||
@@ -87,7 +87,7 @@ const TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'open_note',
|
||||
description: 'Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.',
|
||||
description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -98,7 +98,7 @@ const TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'highlight_editor',
|
||||
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
description: 'Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -110,7 +110,7 @@ const TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'refresh_vault',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -152,7 +152,7 @@ function handleOpenNote(args) {
|
||||
// then signal the UI to open it in a tab.
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in Tolaria` }] }
|
||||
}
|
||||
|
||||
function handleHighlightEditor(args) {
|
||||
@@ -168,7 +168,7 @@ function handleRefreshVault(args) {
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'laputa-mcp-server', version: '0.3.0' },
|
||||
{ name: 'tolaria-mcp-server', version: '0.3.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
@@ -195,7 +195,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
console.error(`Laputa MCP server running (vault: ${VAULT_PATH})`)
|
||||
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
|
||||
4
mcp-server/package-lock.json
generated
4
mcp-server/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "laputa-mcp-server",
|
||||
"name": "tolaria-mcp-server",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "laputa-mcp-server",
|
||||
"name": "tolaria-mcp-server",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "laputa-mcp-server",
|
||||
"name": "tolaria-mcp-server",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP server for Laputa vault operations",
|
||||
"description": "MCP server for Tolaria vault operations",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* WebSocket bridge for Laputa MCP tools.
|
||||
* WebSocket bridge for Tolaria MCP tools.
|
||||
*
|
||||
* Exposes vault operations over WebSocket so the Laputa app frontend
|
||||
* Exposes vault operations over WebSocket so the Tolaria app frontend
|
||||
* can invoke MCP tools in real-time without going through stdio.
|
||||
*
|
||||
* Port 9710: Tool bridge — Claude/AI clients call vault tools here.
|
||||
@@ -74,7 +74,7 @@ async function handleMessage(data) {
|
||||
/**
|
||||
* Attempt to start the UI bridge WebSocket server.
|
||||
* Returns a Promise that resolves to the WebSocketServer or null if the port
|
||||
* is unavailable (e.g. another Laputa instance owns it).
|
||||
* is unavailable (e.g. another Tolaria instance owns it).
|
||||
*/
|
||||
export function startUiBridge(port = WS_UI_PORT) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "laputa-app",
|
||||
"name": "tolaria",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
|
||||
236
src-tauri/Cargo.lock
generated
236
src-tauri/Cargo.lock
generated
@@ -123,16 +123,6 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -619,15 +609,6 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -663,16 +644,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -696,7 +667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
@@ -709,7 +680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1701,25 +1672,6 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1871,11 +1823,9 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -1934,11 +1884,9 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2271,37 +2219,6 @@ dependencies = [
|
||||
"selectors",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "laputa"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
"log",
|
||||
"mockito",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-prevent-default",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -2480,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"
|
||||
@@ -3465,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"
|
||||
@@ -3495,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"
|
||||
@@ -3523,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"
|
||||
@@ -3662,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",
|
||||
@@ -3687,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",
|
||||
]
|
||||
|
||||
@@ -3733,7 +3590,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.5.0",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3889,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",
|
||||
@@ -4021,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",
|
||||
@@ -4395,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"
|
||||
@@ -4600,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"
|
||||
@@ -4642,7 +4472,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dispatch",
|
||||
@@ -5213,7 +5043,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
@@ -5264,6 +5093,35 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tolaria"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
"log",
|
||||
"regex",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-prevent-default",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.5.11"
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "laputa"
|
||||
name = "tolaria"
|
||||
version = "0.1.0"
|
||||
description = "Personal knowledge and life management app"
|
||||
authors = ["you"]
|
||||
@@ -9,7 +9,7 @@ edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[lib]
|
||||
name = "laputa_lib"
|
||||
name = "tolaria_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
@@ -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"
|
||||
|
||||
@@ -28077,7 +28077,7 @@ var TOOLS = [
|
||||
},
|
||||
{
|
||||
name: "open_note",
|
||||
description: "Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.",
|
||||
description: "Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -28088,7 +28088,7 @@ var TOOLS = [
|
||||
},
|
||||
{
|
||||
name: "highlight_editor",
|
||||
description: "Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.",
|
||||
description: "Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -28100,7 +28100,7 @@ var TOOLS = [
|
||||
},
|
||||
{
|
||||
name: "refresh_vault",
|
||||
description: "Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.",
|
||||
description: "Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -28134,7 +28134,7 @@ async function handleGetNote(args) {
|
||||
function handleOpenNote(args) {
|
||||
broadcastUiAction("vault_changed", { path: args.path });
|
||||
broadcastUiAction("open_tab", { path: args.path });
|
||||
return { content: [{ type: "text", text: `Opening ${args.path} in Laputa` }] };
|
||||
return { content: [{ type: "text", text: `Opening ${args.path} in Tolaria` }] };
|
||||
}
|
||||
function handleHighlightEditor(args) {
|
||||
broadcastUiAction("highlight", { element: args.element, path: args.path });
|
||||
@@ -28145,7 +28145,7 @@ function handleRefreshVault(args) {
|
||||
return { content: [{ type: "text", text: "Vault refresh triggered" }] };
|
||||
}
|
||||
var server = new Server(
|
||||
{ name: "laputa-mcp-server", version: "0.3.0" },
|
||||
{ name: "tolaria-mcp-server", version: "0.3.0" },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
@@ -28169,7 +28169,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error(`Laputa MCP server running (vault: ${VAULT_PATH})`);
|
||||
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`);
|
||||
}
|
||||
main().catch(console.error);
|
||||
/*! Bundled license information:
|
||||
|
||||
@@ -500,8 +500,8 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.laputa;
|
||||
PRODUCT_NAME = "laputa";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.tolaria;
|
||||
PRODUCT_NAME = Tolaria;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALID_ARCHS = arm64;
|
||||
@@ -531,8 +531,8 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.laputa;
|
||||
PRODUCT_NAME = "laputa";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.tolaria;
|
||||
PRODUCT_NAME = Tolaria;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALID_ARCHS = arm64;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: laputa
|
||||
name: Tolaria
|
||||
options:
|
||||
bundleIdPrefix: club.refactoring.laputa
|
||||
bundleIdPrefix: club.refactoring.tolaria
|
||||
deploymentTarget:
|
||||
iOS: 14.0
|
||||
fileGroups: [../../src]
|
||||
@@ -10,8 +10,8 @@ configs:
|
||||
settingGroups:
|
||||
app:
|
||||
base:
|
||||
PRODUCT_NAME: laputa
|
||||
PRODUCT_BUNDLE_IDENTIFIER: club.refactoring.laputa
|
||||
PRODUCT_NAME: Tolaria
|
||||
PRODUCT_BUNDLE_IDENTIFIER: club.refactoring.tolaria
|
||||
targetTemplates:
|
||||
app:
|
||||
type: application
|
||||
@@ -85,4 +85,4 @@ targets:
|
||||
basedOnDependencyAnalysis: false
|
||||
outputFiles:
|
||||
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
|
||||
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
|
||||
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
|
||||
|
||||
@@ -208,7 +208,7 @@ fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
let index_js = server_dir.join("index.js");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"laputa": {
|
||||
"tolaria": {
|
||||
"command": "node",
|
||||
"args": [index_js.to_string_lossy()],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
@@ -508,9 +508,9 @@ mod tests {
|
||||
fn build_mcp_config_is_valid_json() {
|
||||
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap();
|
||||
assert!(parsed["mcpServers"]["laputa"]["command"].is_string());
|
||||
assert!(parsed["mcpServers"]["tolaria"]["command"].is_string());
|
||||
assert_eq!(
|
||||
parsed["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
parsed["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
|
||||
"/tmp/test-vault"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +151,13 @@ pub fn init_git_repo(vault_path: String) -> Result<(), String> {
|
||||
crate::git::init_repo(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
crate::git::clone_repo(&url, &local_path)
|
||||
}
|
||||
|
||||
// ── Git commands (mobile stubs) ─────────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -271,3 +278,9 @@ pub fn is_git_repo(_vault_path: String) -> bool {
|
||||
pub fn init_git_repo(_vault_path: String) -> Result<(), String> {
|
||||
Err("Git init is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(_url: String, _local_path: String) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
|
||||
use super::expand_tilde;
|
||||
|
||||
// ── GitHub commands (desktop) ───────────────────────────────────────────────
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
crate::github::github_list_repos(&token).await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_create_repo(
|
||||
token: String,
|
||||
name: String,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
crate::github::github_create_repo(&token, &name, private).await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
crate::github::clone_repo(&url, &token, &local_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
crate::github::github_device_flow_start().await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
crate::github::github_device_flow_poll(&device_code).await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
crate::github::github_get_user(&token).await
|
||||
}
|
||||
|
||||
// ── GitHub commands (mobile stubs) ──────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_list_repos(_token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_create_repo(
|
||||
_token: String,
|
||||
_name: String,
|
||||
_private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(_url: String, _token: String, _local_path: String) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(_device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(_token: String) -> Result<GitHubUser, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
mod ai;
|
||||
mod git;
|
||||
mod github;
|
||||
mod system;
|
||||
mod vault;
|
||||
|
||||
@@ -8,7 +7,6 @@ use std::borrow::Cow;
|
||||
|
||||
pub use ai::*;
|
||||
pub use git::*;
|
||||
pub use github::*;
|
||||
pub use system::*;
|
||||
pub use vault::*;
|
||||
|
||||
@@ -16,16 +14,17 @@ pub use vault::*;
|
||||
/// Returns the original string unchanged if it doesn't start with `~` or if the
|
||||
/// home directory cannot be determined.
|
||||
pub fn expand_tilde(path: &str) -> Cow<'_, str> {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(home.to_string_lossy().into_owned());
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
|
||||
}
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Cow::Borrowed(path);
|
||||
};
|
||||
|
||||
match path {
|
||||
"~" => Cow::Owned(home.to_string_lossy().into_owned()),
|
||||
_ => path
|
||||
.strip_prefix("~/")
|
||||
.map(|rest| Cow::Owned(home.join(rest).to_string_lossy().into_owned()))
|
||||
.unwrap_or(Cow::Borrowed(path)),
|
||||
}
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
pub fn parse_build_label(version: &str) -> String {
|
||||
|
||||
163
src-tauri/src/git/clone.rs
Normal file
163
src-tauri/src/git/clone.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Clone a git repository to a local path using the system git configuration.
|
||||
pub fn clone_repo(url: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
prepare_clone_destination(dest)?;
|
||||
|
||||
if let Err(err) = run_clone(url, dest) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(format!("Cloned to {}", dest.display()))
|
||||
}
|
||||
|
||||
fn prepare_clone_destination(dest: &Path) -> Result<(), String> {
|
||||
if !dest.exists() {
|
||||
return ensure_parent_directory(dest);
|
||||
}
|
||||
|
||||
ensure_empty_directory(dest)
|
||||
}
|
||||
|
||||
fn ensure_empty_directory(dest: &Path) -> Result<(), String> {
|
||||
if !dest.is_dir() {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not a directory",
|
||||
dest.display()
|
||||
));
|
||||
}
|
||||
|
||||
if directory_has_entries(dest)? {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
dest.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_parent_directory(dest: &Path) -> Result<(), String> {
|
||||
let Some(parent) = dest.parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if parent.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create parent directory for '{}': {}",
|
||||
dest.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn directory_has_entries(dest: &Path) -> Result<bool, String> {
|
||||
dest.read_dir()
|
||||
.map_err(|e| format!("Failed to inspect destination '{}': {}", dest.display(), e))
|
||||
.map(|mut entries| entries.next().is_some())
|
||||
}
|
||||
|
||||
fn run_clone(url: &str, dest: &Path) -> Result<(), String> {
|
||||
let destination = dest
|
||||
.to_str()
|
||||
.ok_or_else(|| format!("Destination '{}' is not valid UTF-8", dest.display()))?;
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", url, destination])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("git clone failed: {}", stderr.trim()))
|
||||
}
|
||||
|
||||
fn cleanup_failed_clone(dest: &Path) {
|
||||
if dest.exists() && dest.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn init_source_repo(path: &Path) {
|
||||
fs::create_dir_all(path).unwrap();
|
||||
fs::write(path.join("welcome.md"), "# Welcome\n").unwrap();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "tolaria@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Tolaria App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_clones_local_repository() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("source");
|
||||
let dest = dir.path().join("dest");
|
||||
init_source_repo(&source);
|
||||
|
||||
let result = clone_repo(source.to_str().unwrap(), dest.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(result, format!("Cloned to {}", dest.to_string_lossy()));
|
||||
assert!(dest.join(".git").exists());
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo("https://example.com/repo.git", dir.path().to_str().unwrap());
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
fs::create_dir(&dest).unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://example.com/nonexistent/repo.git",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod clone;
|
||||
mod commit;
|
||||
mod conflict;
|
||||
mod dates;
|
||||
@@ -9,6 +10,7 @@ mod status;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub use clone::clone_repo;
|
||||
pub use commit::git_commit;
|
||||
pub use conflict::{
|
||||
get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict,
|
||||
@@ -35,7 +37,7 @@ pub struct GitCommit {
|
||||
pub date: i64,
|
||||
}
|
||||
|
||||
const DEFAULT_GITIGNORE: &str = "# Laputa app files (machine-specific, never commit)\n\
|
||||
const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never commit)\n\
|
||||
.laputa/settings.json\n\
|
||||
\n\
|
||||
# macOS\n\
|
||||
@@ -101,7 +103,10 @@ fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
|
||||
/// Set local user.name and user.email if not already configured.
|
||||
fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [("user.name", "Laputa"), ("user.email", "vault@laputa.app")] {
|
||||
for (key, fallback) in [
|
||||
("user.name", "Tolaria"),
|
||||
("user.email", "vault@tolaria.app"),
|
||||
] {
|
||||
let check = Command::new("git")
|
||||
.args(["config", key])
|
||||
.current_dir(dir)
|
||||
@@ -119,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)]
|
||||
@@ -150,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();
|
||||
@@ -345,42 +356,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_https() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://github.com/owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://github.com/owner/repo"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_ssh() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("git@github.com:owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("git@github.com:owner/repo"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_token_embedded() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://gho_abc123@github.com/owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
fn test_parse_github_repo_path_variants() {
|
||||
for url in [
|
||||
"https://github.com/owner/repo.git",
|
||||
"https://github.com/owner/repo",
|
||||
"http://github.com/owner/repo.git",
|
||||
"git@github.com:owner/repo.git",
|
||||
"git@github.com:owner/repo",
|
||||
"ssh://git@github.com/owner/repo.git",
|
||||
"https://gho_abc123@github.com/owner/repo.git",
|
||||
] {
|
||||
assert_repo_path(url, Some("owner/repo"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_non_github() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://gitlab.com/owner/repo.git"),
|
||||
None
|
||||
);
|
||||
assert_repo_path("https://gitlab.com/owner/repo.git", None);
|
||||
assert_repo_path("owner/repo", None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
use super::{GitHubUser, GithubRepo};
|
||||
|
||||
/// Lists the authenticated user's GitHub repositories.
|
||||
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
github_list_repos_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_list_repos_with_base(
|
||||
token: &str,
|
||||
api_base: &str,
|
||||
) -> Result<Vec<GithubRepo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut all_repos: Vec<GithubRepo> = Vec::new();
|
||||
let mut page = 1u32;
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/user/repos?per_page=100&sort=updated&page={}",
|
||||
api_base, page
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
let repos: Vec<GithubRepo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
|
||||
|
||||
let count = repos.len();
|
||||
all_repos.extend(repos);
|
||||
|
||||
if count < 100 {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
if page > 10 {
|
||||
break; // safety limit: 1000 repos max
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_repos)
|
||||
}
|
||||
|
||||
/// 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": "Laputa vault"
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/user/repos", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 422 && body.contains("name already exists") {
|
||||
return Err("Repository name already exists on your account".to_string());
|
||||
}
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
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", "Laputa-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":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repo.name, "new-repo");
|
||||
assert!(repo.private);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_name_exists() {
|
||||
let err = mock_create_repo(
|
||||
422,
|
||||
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Repository name already exists"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_server_error() {
|
||||
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 500"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_success() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
user,
|
||||
GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_unauthorized() {
|
||||
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 401"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_null_name() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.login, "bot-account");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{DeviceFlowPollResult, DeviceFlowStart, GITHUB_CLIENT_ID};
|
||||
|
||||
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github_device_flow_start_with_base("https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/device/code", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-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", "Laputa-App")
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
("device_code", device_code),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow poll failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Device flow poll HTTP error: {}", body));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResponse {
|
||||
access_token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
let raw: RawResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
|
||||
|
||||
if let Some(token) = raw.access_token {
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some(token),
|
||||
error: None,
|
||||
})
|
||||
} else {
|
||||
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
|
||||
let status = match error.as_str() {
|
||||
"authorization_pending" | "slow_down" => "pending",
|
||||
"expired_token" => "expired",
|
||||
_ => "error",
|
||||
};
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: status.to_string(),
|
||||
access_token: None,
|
||||
error: Some(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
|
||||
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
|
||||
let result = github_device_flow_start_with_base(&server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
|
||||
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
|
||||
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_success() {
|
||||
let start = mock_device_start(
|
||||
200,
|
||||
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
start,
|
||||
DeviceFlowStart {
|
||||
device_code: "dev_abc".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_error() {
|
||||
let err = mock_device_start(400, "bad request").await.unwrap_err();
|
||||
assert!(err.contains("Device flow start failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_404_gives_clear_message() {
|
||||
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("device flow not available"));
|
||||
assert!(err.contains("Device authorization flow"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_complete() {
|
||||
let poll =
|
||||
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
|
||||
.await;
|
||||
assert_eq!(
|
||||
poll,
|
||||
DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_secret123".to_string()),
|
||||
error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_pending() {
|
||||
let poll = mock_poll(
|
||||
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("authorization_pending".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_slow_down() {
|
||||
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("slow_down".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_expired() {
|
||||
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
|
||||
assert_eq!(poll.status, "expired");
|
||||
assert_eq!(poll.error, Some("expired_token".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_other_error() {
|
||||
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("access_denied".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(503)
|
||||
.with_body("Service Unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
mock.assert_async().await;
|
||||
assert!(err.contains("Device flow poll HTTP error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_unknown_error() {
|
||||
let poll = mock_poll(r#"{}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("unknown".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Clones a GitHub repo to a local path using HTTPS + token auth.
|
||||
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
prepare_clone_destination(dest, local_path)?;
|
||||
|
||||
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
|
||||
let auth_url = inject_token_into_url(url, token)?;
|
||||
|
||||
if let Err(err) = run_clone(&auth_url, local_path) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Configure the remote to use token auth for future pushes
|
||||
if let Err(err) = configure_remote_auth(local_path, url, token) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Ensure sensible .gitignore defaults (especially .DS_Store on macOS)
|
||||
crate::git::ensure_gitignore(local_path)?;
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Clones a public repo to a local path without modifying the remote URL.
|
||||
pub fn clone_public_repo(url: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
prepare_clone_destination(dest, local_path)?;
|
||||
|
||||
if let Err(err) = run_clone(url, local_path) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Injects an OAuth token into an HTTPS GitHub URL.
|
||||
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
|
||||
} else if let Some(rest) = url.strip_prefix("https://") {
|
||||
// Handle URLs that already have a host
|
||||
Ok(format!("https://oauth2:{}@{}", token, rest))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported URL format: {}. Use an HTTPS URL.",
|
||||
url
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_clone_destination(dest: &Path, local_path: &str) -> Result<(), String> {
|
||||
if dest.exists() {
|
||||
if !dest.is_dir() {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not a directory",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
let has_entries = dest
|
||||
.read_dir()
|
||||
.map_err(|e| format!("Failed to inspect destination '{}': {}", local_path, e))?
|
||||
.next()
|
||||
.is_some();
|
||||
if has_entries {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create parent directory for '{}': {}",
|
||||
local_path, e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_clone(url: &str, local_path: &str) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", url, local_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("git clone failed: {}", stderr.trim()))
|
||||
}
|
||||
|
||||
fn cleanup_failed_clone(dest: &Path) {
|
||||
if dest.exists() && dest.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the git remote to use token-based HTTPS auth.
|
||||
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
|
||||
let auth_url = inject_token_into_url(original_url, token)?;
|
||||
let vault = Path::new(local_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "set-url", "origin", &auth_url])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to configure remote: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to set remote URL: {}", stderr));
|
||||
}
|
||||
|
||||
// Also configure git user if not set
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
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", "laputa@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Initial vault"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_public_repo_clones_local_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("source");
|
||||
let dest = dir.path().join("dest");
|
||||
init_local_repo(&source);
|
||||
|
||||
let result = clone_public_repo(source.to_str().unwrap(), dest.to_str().unwrap());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
format!("Cloned to {}", dest.to_string_lossy())
|
||||
);
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
|
||||
let status = StdCommand::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&dest)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&status.stdout).trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_public_repo_cleans_failed_clone_destination() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("dest");
|
||||
let missing = dir.path().join("missing-repo");
|
||||
|
||||
let result = clone_public_repo(missing.to_str().unwrap(), dest.to_str().unwrap());
|
||||
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
mod api;
|
||||
mod auth;
|
||||
mod clone;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use api::{github_create_repo, github_get_user, github_list_repos};
|
||||
pub use auth::{github_device_flow_poll, github_device_flow_start};
|
||||
pub use clone::{clone_public_repo, clone_repo};
|
||||
|
||||
/// GitHub App client ID for OAuth device flow.
|
||||
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
|
||||
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
|
||||
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GithubRepo {
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub description: Option<String>,
|
||||
pub private: bool,
|
||||
pub clone_url: String,
|
||||
pub html_url: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowStart {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowPollResult {
|
||||
pub status: String,
|
||||
pub access_token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GitHubUser {
|
||||
pub login: String,
|
||||
pub name: Option<String>,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_serialization_roundtrip() {
|
||||
let repo = GithubRepo {
|
||||
name: "test-repo".to_string(),
|
||||
full_name: "user/test-repo".to_string(),
|
||||
description: Some("A test repo".to_string()),
|
||||
private: true,
|
||||
clone_url: "https://github.com/user/test-repo.git".to_string(),
|
||||
html_url: "https://github.com/user/test-repo".to_string(),
|
||||
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&repo).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_deserialization_null_fields() {
|
||||
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
|
||||
let repo: GithubRepo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(repo.name, "r");
|
||||
assert!(!repo.private);
|
||||
assert!(repo.description.is_none());
|
||||
assert!(repo.updated_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_start_serialization_roundtrip() {
|
||||
let start = DeviceFlowStart {
|
||||
device_code: "dc_123".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
};
|
||||
let json = serde_json::to_string(&start).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_poll_result_roundtrip() {
|
||||
let complete = DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_abc123".to_string()),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&complete).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
complete
|
||||
);
|
||||
|
||||
let pending = DeviceFlowPollResult {
|
||||
status: "pending".to_string(),
|
||||
access_token: None,
|
||||
error: Some("authorization_pending".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&pending).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
pending
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_serialization_roundtrip() {
|
||||
let user = GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&user).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_deserialization_null_name() {
|
||||
let user: GitHubUser =
|
||||
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
|
||||
.unwrap();
|
||||
assert_eq!(user.login, "bot");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ pub mod claude_cli;
|
||||
mod commands;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod mcp;
|
||||
#[cfg(desktop)]
|
||||
pub mod menu;
|
||||
@@ -48,7 +47,7 @@ fn run_startup_tasks() {
|
||||
// Seed AGENTS.md and config.md at vault root if missing
|
||||
vault::seed_config_files(vp_str);
|
||||
|
||||
// Register Laputa MCP server in Claude Code and Cursor configs
|
||||
// Register Tolaria MCP server in Claude Code and Cursor configs
|
||||
match mcp::register_mcp(vp_str) {
|
||||
Ok(status) => log::info!("MCP registration: {status}"),
|
||||
Err(e) => log::warn!("MCP registration failed: {e}"),
|
||||
@@ -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,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
laputa_lib::run();
|
||||
tolaria_lib::run();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
|
||||
const MCP_SERVER_NAME: &str = "tolaria";
|
||||
const LEGACY_MCP_SERVER_NAME: &str = "laputa";
|
||||
|
||||
/// Status of the MCP server installation.
|
||||
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -20,11 +23,45 @@ pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
.arg("node")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run `which node`: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err("node not found in PATH".into());
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok(PathBuf::from(path))
|
||||
|
||||
if let Some(path) = fallback_node_path() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
Err("node not found in PATH or common install locations".into())
|
||||
}
|
||||
|
||||
fn fallback_node_path() -> Option<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/opt/homebrew/bin/node"),
|
||||
PathBuf::from("/usr/local/bin/node"),
|
||||
];
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.push(home.join(".volta").join("bin").join("node"));
|
||||
|
||||
let nvm_dir = home.join(".nvm").join("versions").join("node");
|
||||
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
|
||||
let mut versions = entries
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.collect::<Vec<_>>();
|
||||
versions.sort();
|
||||
versions.reverse();
|
||||
candidates.extend(
|
||||
versions
|
||||
.into_iter()
|
||||
.map(|version| version.join("bin").join("node")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.into_iter().find(|path| path.is_file())
|
||||
}
|
||||
|
||||
/// Resolve the path to `mcp-server/`.
|
||||
@@ -102,7 +139,7 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
|
||||
status.to_string()
|
||||
}
|
||||
|
||||
/// Register Laputa as an MCP server in Claude Code and Cursor config files.
|
||||
/// Register Tolaria as an MCP server in Claude Code and Cursor config files.
|
||||
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
@@ -120,7 +157,7 @@ pub fn register_mcp(vault_path: &str) -> Result<String, String> {
|
||||
Ok(register_mcp_to_configs(&entry, &configs))
|
||||
}
|
||||
|
||||
/// Insert or update the "laputa" entry in an MCP config file.
|
||||
/// Insert or update the Tolaria entry in an MCP config file.
|
||||
fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bool, String> {
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
@@ -142,12 +179,14 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
|
||||
.entry("mcpServers")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
let was_update = servers.get("laputa").is_some();
|
||||
|
||||
servers
|
||||
let servers = servers
|
||||
.as_object_mut()
|
||||
.ok_or("mcpServers is not a JSON object")?
|
||||
.insert("laputa".to_string(), entry.clone());
|
||||
.ok_or("mcpServers is not a JSON object")?;
|
||||
|
||||
let was_update =
|
||||
servers.get(MCP_SERVER_NAME).is_some() || servers.get(LEGACY_MCP_SERVER_NAME).is_some();
|
||||
servers.remove(LEGACY_MCP_SERVER_NAME);
|
||||
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
|
||||
|
||||
let json = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("Failed to serialize config: {e}"))?;
|
||||
@@ -159,7 +198,7 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
|
||||
|
||||
/// Check whether the MCP server is properly installed and registered.
|
||||
///
|
||||
/// Returns `Installed` when the laputa entry exists in `~/.claude/mcp.json`
|
||||
/// Returns `Installed` when the Tolaria entry exists in `~/.claude/mcp.json`
|
||||
/// and the referenced index.js file is present. Returns `NoClaudeCli` when
|
||||
/// the Claude CLI binary cannot be found. Otherwise returns `NotInstalled`.
|
||||
pub fn check_mcp_status() -> McpStatus {
|
||||
@@ -187,10 +226,15 @@ pub fn check_mcp_status() -> McpStatus {
|
||||
Err(_) => return McpStatus::NotInstalled,
|
||||
};
|
||||
|
||||
let entry = &config["mcpServers"]["laputa"];
|
||||
if entry.is_null() {
|
||||
let Some(servers) = config.get("mcpServers").and_then(|value| value.as_object()) else {
|
||||
return McpStatus::NotInstalled;
|
||||
}
|
||||
};
|
||||
let Some(entry) = servers
|
||||
.get(MCP_SERVER_NAME)
|
||||
.or_else(|| servers.get(LEGACY_MCP_SERVER_NAME))
|
||||
else {
|
||||
return McpStatus::NotInstalled;
|
||||
};
|
||||
|
||||
// Verify the referenced index.js actually exists on disk
|
||||
if let Some(index_js) = entry["args"]
|
||||
@@ -231,9 +275,12 @@ mod tests {
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
|
||||
assert_eq!(
|
||||
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
"/test/vault"
|
||||
);
|
||||
}
|
||||
@@ -253,11 +300,40 @@ mod tests {
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(
|
||||
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
"/vault/v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_migrates_legacy_server_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
|
||||
let existing = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"laputa": {
|
||||
"command": "node",
|
||||
"args": ["/old/index.js"],
|
||||
"env": { "VAULT_PATH": "/old" }
|
||||
}
|
||||
}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert!(config["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null());
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_other_servers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -276,7 +352,7 @@ mod tests {
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert!(config["mcpServers"]["other-server"].is_object());
|
||||
assert!(config["mcpServers"]["laputa"].is_object());
|
||||
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -358,7 +434,10 @@ mod tests {
|
||||
|
||||
let raw = std::fs::read_to_string(&claude_cfg).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn upsert_returns_error_for_invalid_json() {
|
||||
|
||||
@@ -128,7 +128,7 @@ fn build_app_menu(app: &App) -> MenuResult {
|
||||
.id(APP_CHECK_FOR_UPDATES)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Laputa")
|
||||
Ok(SubmenuBuilder::new(app, "Tolaria")
|
||||
.about(None)
|
||||
.separator()
|
||||
.item(&check_updates_item)
|
||||
|
||||
@@ -2,10 +2,11 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const APP_CONFIG_DIR: &str = "com.tolaria.app";
|
||||
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>,
|
||||
@@ -14,10 +15,32 @@ pub struct Settings {
|
||||
pub release_channel: Option<String>,
|
||||
}
|
||||
|
||||
fn app_config_dir() -> Result<PathBuf, String> {
|
||||
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
||||
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
|
||||
}
|
||||
|
||||
fn resolve_existing_or_preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
||||
let preferred = preferred_app_config_path(file_name)?;
|
||||
if preferred.exists() {
|
||||
return Ok(preferred);
|
||||
}
|
||||
|
||||
let legacy = app_config_dir()?
|
||||
.join(LEGACY_APP_CONFIG_DIR)
|
||||
.join(file_name);
|
||||
if legacy.exists() {
|
||||
return Ok(legacy);
|
||||
}
|
||||
|
||||
Ok(preferred)
|
||||
}
|
||||
|
||||
fn settings_path() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("settings.json"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
resolve_existing_or_preferred_app_config_path("settings.json")
|
||||
}
|
||||
|
||||
fn get_settings_at(path: &PathBuf) -> Result<Settings, String> {
|
||||
@@ -35,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,
|
||||
@@ -69,13 +84,11 @@ pub fn get_settings() -> Result<Settings, String> {
|
||||
}
|
||||
|
||||
pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
save_settings_at(&settings_path()?, settings)
|
||||
save_settings_at(&preferred_app_config_path("settings.json")?, settings)
|
||||
}
|
||||
|
||||
fn last_vault_file() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("last-vault.txt"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
resolve_existing_or_preferred_app_config_path("last-vault.txt")
|
||||
}
|
||||
|
||||
fn get_last_vault_at(path: &PathBuf) -> Option<String> {
|
||||
@@ -99,13 +112,40 @@ pub fn get_last_vault() -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
|
||||
set_last_vault_at(&last_vault_file()?, vault_path)
|
||||
set_last_vault_at(&preferred_app_config_path("last-vault.txt")?, vault_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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();
|
||||
@@ -114,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]
|
||||
@@ -152,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]
|
||||
@@ -196,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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,31 +266,51 @@ 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]
|
||||
fn test_settings_path_returns_ok() {
|
||||
let result = settings_path();
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
let path = result.unwrap();
|
||||
let path = path.to_str().unwrap();
|
||||
assert!(path.contains("com.tolaria.app") || path.contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preferred_settings_path_uses_tolaria_namespace() {
|
||||
let result = preferred_app_config_path("settings.json");
|
||||
assert!(result.is_ok());
|
||||
assert!(result
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("com.tolaria.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -263,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]
|
||||
@@ -293,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ mod tests {
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Laputa Vault"));
|
||||
assert!(content.contains("Tolaria Vault"));
|
||||
// Must NOT create config/ directory
|
||||
assert!(!vault.join("config").exists());
|
||||
}
|
||||
@@ -205,7 +205,7 @@ mod tests {
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Laputa Vault"));
|
||||
assert!(content.contains("Tolaria Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -272,7 +272,7 @@ mod tests {
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("Laputa Vault"));
|
||||
assert!(root.contains("Tolaria Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -305,7 +305,7 @@ mod tests {
|
||||
assert!(!vault.join("config").exists());
|
||||
|
||||
let agents = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(agents.contains("Laputa Vault"));
|
||||
assert!(agents.contains("Tolaria Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,9 +19,9 @@ pub fn vault_exists(path: &str) -> bool {
|
||||
/// Default AGENTS.md content — vault instructions for AI agents.
|
||||
/// Describes Laputa vault mechanics only; no vault-specific structure.
|
||||
/// The vault scanner will pick this up as a regular entry.
|
||||
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Laputa Vault
|
||||
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
|
||||
This is a [Laputa](https://github.com/refactoringhq/laputa-app) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
## Note structure
|
||||
|
||||
@@ -115,12 +115,13 @@ 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)
|
||||
}
|
||||
|
||||
fn getting_started_repo_url() -> String {
|
||||
std::env::var("LAPUTA_GETTING_STARTED_REPO_URL")
|
||||
std::env::var("TOLARIA_GETTING_STARTED_REPO_URL")
|
||||
.or_else(|_| std::env::var("LAPUTA_GETTING_STARTED_REPO_URL"))
|
||||
.unwrap_or_else(|_| GETTING_STARTED_REPO_URL.to_string())
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ mod tests {
|
||||
fs::create_dir_all(path.join("views")).unwrap();
|
||||
fs::write(
|
||||
path.join("welcome.md"),
|
||||
"# Welcome to Laputa\n\nThis is the starter vault.\n",
|
||||
"# Welcome to Tolaria\n\nThis is the starter vault.\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
@@ -158,12 +159,12 @@ mod tests {
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.args(["config", "user.email", "tolaria@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.args(["config", "user.name", "Tolaria App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
@@ -2,6 +2,9 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const APP_CONFIG_DIR: &str = "com.tolaria.app";
|
||||
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VaultEntry {
|
||||
pub label: String,
|
||||
@@ -16,10 +19,32 @@ pub struct VaultList {
|
||||
pub hidden_defaults: Vec<String>,
|
||||
}
|
||||
|
||||
fn app_config_dir() -> Result<PathBuf, String> {
|
||||
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
||||
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
|
||||
}
|
||||
|
||||
fn resolve_existing_or_preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
||||
let preferred = preferred_app_config_path(file_name)?;
|
||||
if preferred.exists() {
|
||||
return Ok(preferred);
|
||||
}
|
||||
|
||||
let legacy = app_config_dir()?
|
||||
.join(LEGACY_APP_CONFIG_DIR)
|
||||
.join(file_name);
|
||||
if legacy.exists() {
|
||||
return Ok(legacy);
|
||||
}
|
||||
|
||||
Ok(preferred)
|
||||
}
|
||||
|
||||
fn vault_list_path() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("vaults.json"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
resolve_existing_or_preferred_app_config_path("vaults.json")
|
||||
}
|
||||
|
||||
fn load_at(path: &PathBuf) -> Result<VaultList, String> {
|
||||
@@ -46,7 +71,7 @@ pub fn load_vault_list() -> Result<VaultList, String> {
|
||||
}
|
||||
|
||||
pub fn save_vault_list(list: &VaultList) -> Result<(), String> {
|
||||
save_at(&vault_list_path()?, list)
|
||||
save_at(&preferred_app_config_path("vaults.json")?, list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -131,7 +156,20 @@ mod tests {
|
||||
fn vault_list_path_returns_ok() {
|
||||
let result = vault_list_path();
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
let path = result.unwrap();
|
||||
let path = path.to_str().unwrap();
|
||||
assert!(path.contains("com.tolaria.app") || path.contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_vault_list_path_uses_tolaria_namespace() {
|
||||
let result = preferred_app_config_path("vaults.json");
|
||||
assert!(result.is_ok());
|
||||
assert!(result
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("com.tolaria.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "laputa",
|
||||
"productName": "Tolaria",
|
||||
"version": "0.1.0",
|
||||
"identifier": "club.refactoring.laputa",
|
||||
"identifier": "club.refactoring.tolaria",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5202",
|
||||
@@ -13,7 +13,7 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Laputa",
|
||||
"title": "Tolaria",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 1200,
|
||||
@@ -54,9 +54,9 @@
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://refactoringhq.github.io/laputa-app/latest.json"
|
||||
"https://refactoringhq.github.io/tolaria/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE4NkQ5MDI3REVCRkFGNUMKUldSY3I3L2VKNUJ0cU5JRlRZZlp3NGhnU3ZwbkVKeGVvREpmb2sxRVJndHFpVFZPNlArbEE5R1IK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -128,7 +128,10 @@ describe('App', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset view mode and onboarding state between tests
|
||||
localStorage.removeItem('tolaria-view-mode')
|
||||
localStorage.removeItem('tolaria-view-mode')
|
||||
localStorage.removeItem('laputa-view-mode')
|
||||
localStorage.removeItem('tolaria_welcome_dismissed')
|
||||
localStorage.removeItem('laputa_welcome_dismissed')
|
||||
})
|
||||
|
||||
|
||||
32
src/App.tsx
32
src/App.tsx
@@ -14,7 +14,7 @@ import { CommitDialog } from './components/CommitDialog'
|
||||
import { PulseView } from './components/PulseView'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { GitHubVaultModal } from './components/GitHubVaultModal'
|
||||
import { CloneVaultModal } from './components/CloneVaultModal'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
|
||||
import { FeedbackDialog } from './components/FeedbackDialog'
|
||||
@@ -64,6 +64,7 @@ import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
|
||||
import {
|
||||
INBOX_SELECTION,
|
||||
isExplicitOrganizationEnabled,
|
||||
@@ -97,10 +98,20 @@ function App() {
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
const { showAIChat, toggleAIChat } = dialogs
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
const openFeedback = useCallback(() => setShowFeedback(true), [])
|
||||
const closeFeedback = useCallback(() => setShowFeedback(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenAiChat = () => {
|
||||
if (!showAIChat) toggleAIChat()
|
||||
}
|
||||
|
||||
window.addEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
||||
return () => window.removeEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
||||
}, [showAIChat, toggleAIChat])
|
||||
|
||||
// onSwitch closure captures `notes` declared below — safe because it's only
|
||||
// called on user interaction, never during render (refs inside the hook
|
||||
// guarantee the latest closure is always used).
|
||||
@@ -726,10 +737,16 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
entries={vault.entries}
|
||||
claudeCodeReady={claudeCodeStatus === 'installed'}
|
||||
onClose={dialogs.closeCommandPalette}
|
||||
/>
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
|
||||
@@ -747,14 +764,7 @@ function App() {
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<GitHubVaultModal
|
||||
open={dialogs.showGitHubVault}
|
||||
githubToken={settings.github_token}
|
||||
onClose={dialogs.closeGitHubVault}
|
||||
onVaultCloned={vaultSwitcher.handleVaultCloned}
|
||||
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
|
||||
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
|
||||
/>
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
{deleteActions.confirmDelete && (
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
|
||||
@@ -31,7 +31,7 @@ const TOOL_ICON_MAP: Record<string, IconRenderer> = {
|
||||
Read: (s) => <File size={s} />,
|
||||
Glob: (s) => <FolderOpen size={s} />,
|
||||
Grep: (s) => <MagnifyingGlass size={s} />,
|
||||
// Laputa MCP tools
|
||||
// Tolaria MCP tools
|
||||
search_notes: (s) => <MagnifyingGlass size={s} />,
|
||||
get_vault_context: (s) => <ChartBar size={s} />,
|
||||
get_note: (s) => <File size={s} />,
|
||||
@@ -100,7 +100,7 @@ function DetailBlock({ label, content, isError }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether this tool is a Laputa UI-only tool (lighter styling). */
|
||||
/** Whether this tool is a Tolaria UI-only tool (lighter styling). */
|
||||
function isUiOnlyTool(tool: string): boolean {
|
||||
return tool === 'open_note'
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt } from '../utils/aiPromptBridge'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
|
||||
const mockSendMessage = vi.fn()
|
||||
const mockClearConversation = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
useAiAgent: () => ({
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
sendMessage: mockSendMessage,
|
||||
clearConversation: mockClearConversation,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -48,6 +52,8 @@ describe('AiPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockMessages = []
|
||||
mockStatus = 'idle'
|
||||
mockSendMessage.mockReset()
|
||||
mockClearConversation.mockReset()
|
||||
})
|
||||
|
||||
it('renders panel with AI Chat header', () => {
|
||||
@@ -114,7 +120,7 @@ describe('AiPanel', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toBeTruthy()
|
||||
expect((input as HTMLInputElement).disabled).toBe(false)
|
||||
expect(input).toHaveAttribute('contenteditable', 'true')
|
||||
})
|
||||
|
||||
it('has send button disabled when input is empty', () => {
|
||||
@@ -128,14 +134,14 @@ describe('AiPanel', () => {
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask about this note...')
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask about this note...')
|
||||
})
|
||||
|
||||
it('shows generic placeholder when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask the AI agent...')
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask the AI agent...')
|
||||
})
|
||||
|
||||
it('auto-focuses input on mount', async () => {
|
||||
@@ -203,4 +209,20 @@ describe('AiPanel', () => {
|
||||
fireEvent.click(wikilinks[1])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
|
||||
})
|
||||
|
||||
it('auto-sends a queued prompt from the command palette bridge', async () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" entries={[makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' })]} />)
|
||||
|
||||
await act(async () => {
|
||||
queueAiPrompt('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockClearConversation).toHaveBeenCalledOnce()
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
expect(screen.getByTestId('agent-send')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import { type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
import { type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
|
||||
import { useAiPanelFocus } from './useAiPanelFocus'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
|
||||
@@ -111,75 +115,89 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
|
||||
)
|
||||
}
|
||||
|
||||
function AiPanelComposer({
|
||||
entries,
|
||||
hasContext,
|
||||
input,
|
||||
inputRef,
|
||||
isActive,
|
||||
onChange,
|
||||
onSend,
|
||||
}: {
|
||||
entries: VaultEntry[]
|
||||
hasContext: boolean
|
||||
input: string
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
isActive: boolean
|
||||
onChange: (value: string) => void
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={isActive}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: pendingRefs.length > 0 ? pendingRefs : undefined,
|
||||
})
|
||||
}, [activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
const {
|
||||
agent,
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
} = useAiPanelController({
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
})
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
} else {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && panelRef.current?.contains(document.activeElement)) {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
setPendingRefs(references)
|
||||
agent.sendMessage(text, references)
|
||||
setInput('')
|
||||
}, [isActive, agent])
|
||||
useAiPanelPromptQueue({ agent, input, isActive, setInput })
|
||||
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -208,39 +226,15 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries ?? []}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
disabled={isActive}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={() => handleSend(input, pendingRefs)}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AiPanelComposer
|
||||
entries={entries ?? []}
|
||||
hasContext={hasContext}
|
||||
input={input}
|
||||
inputRef={inputRef}
|
||||
isActive={isActive}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ describe('BreadcrumbBar — drag region', () => {
|
||||
const bar = container.firstElementChild as HTMLElement
|
||||
expect(bar.dataset.tauriDragRegion).toBeDefined()
|
||||
})
|
||||
|
||||
it('marks the center spacer as a drag region', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
const spacer = container.querySelector('.breadcrumb-bar__drag-spacer')
|
||||
expect(spacer).toHaveAttribute('data-tauri-drag-region')
|
||||
expect(spacer).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — delete', () => {
|
||||
|
||||
@@ -529,9 +529,14 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div className="breadcrumb-bar__title flex-1 min-w-0">
|
||||
<div className="breadcrumb-bar__title min-w-0">
|
||||
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-tauri-drag-region
|
||||
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
|
||||
/>
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
)
|
||||
|
||||
85
src/components/CloneVaultModal.test.tsx
Normal file
85
src/components/CloneVaultModal.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CloneVaultModal } from './CloneVaultModal'
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
|
||||
const mockInvokeFn = vi.mocked(mockInvoke)
|
||||
|
||||
describe('CloneVaultModal', () => {
|
||||
const onClose = vi.fn()
|
||||
const onVaultCloned = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockResolvedValue('Cloned successfully')
|
||||
})
|
||||
|
||||
it('renders nothing when not open', () => {
|
||||
const { container } = render(
|
||||
<CloneVaultModal open={false} onClose={onClose} onVaultCloned={onVaultCloned} />
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-testid="clone-vault-modal"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the clone form when open', () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
expect(screen.getByText('Clone Git Repo')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('clone-repo-url')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('clone-vault-path')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('suggests a vault path from the repository URL', () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'https://gitlab.com/user/my-vault.git' },
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('clone-vault-path')).toHaveValue('~/Vaults/my-vault')
|
||||
})
|
||||
|
||||
it('calls clone_repo and reports the cloned vault on submit', async () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'git@github.com:user/my-vault.git' },
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('clone-vault-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('clone_repo', {
|
||||
url: 'git@github.com:user/my-vault.git',
|
||||
localPath: '~/Vaults/my-vault',
|
||||
})
|
||||
})
|
||||
|
||||
expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the backend error when cloning fails', async () => {
|
||||
mockInvokeFn.mockRejectedValueOnce(new Error('Permission denied'))
|
||||
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'https://example.com/user/private-vault.git' },
|
||||
})
|
||||
fireEvent.change(screen.getByTestId('clone-vault-path'), {
|
||||
target: { value: '~/Vaults/private-vault' },
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('clone-vault-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('clone-vault-error')).toHaveTextContent('Clone failed: Error: Permission denied')
|
||||
})
|
||||
})
|
||||
})
|
||||
204
src/components/CloneVaultModal.tsx
Normal file
204
src/components/CloneVaultModal.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
|
||||
type CloneStatus = 'idle' | 'cloning' | 'error'
|
||||
|
||||
interface CloneVaultModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onVaultCloned: (path: string, label: string) => void
|
||||
}
|
||||
|
||||
interface CloneVaultFormState {
|
||||
repoUrl: string
|
||||
localPath: string
|
||||
cloneStatus: CloneStatus
|
||||
cloneError: string | null
|
||||
isCloneDisabled: boolean
|
||||
handleClose: () => void
|
||||
handleRepoUrlChange: (value: string) => void
|
||||
handleLocalPathChange: (value: string) => void
|
||||
handleClone: () => Promise<void>
|
||||
}
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
function repoNameFromUrl(url: string): string {
|
||||
const trimmed = url.trim().replace(/\/+$/g, '')
|
||||
if (!trimmed) return ''
|
||||
const segment = trimmed.split(/[/:]/).pop() ?? ''
|
||||
return segment.replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
function suggestedPathFromUrl(url: string): string {
|
||||
const repoName = repoNameFromUrl(url)
|
||||
return repoName ? `~/Vaults/${repoName}` : ''
|
||||
}
|
||||
|
||||
function labelFromPath(path: string): string {
|
||||
const trimmed = path.trim().replace(/\/+$/g, '')
|
||||
return trimmed.split('/').pop() || 'Vault'
|
||||
}
|
||||
|
||||
function shouldSyncSuggestedPath(localPath: string, pathDirty: boolean, previousSuggestedPath: string): boolean {
|
||||
return !pathDirty || !localPath.trim() || localPath === previousSuggestedPath
|
||||
}
|
||||
|
||||
function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, label: string) => void): CloneVaultFormState {
|
||||
const [repoUrl, setRepoUrl] = useState('')
|
||||
const [localPath, setLocalPath] = useState('')
|
||||
const [pathDirty, setPathDirty] = useState(false)
|
||||
const [cloneStatus, setCloneStatus] = useState<CloneStatus>('idle')
|
||||
const [cloneError, setCloneError] = useState<string | null>(null)
|
||||
const previousSuggestedPathRef = useRef('')
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setRepoUrl('')
|
||||
setLocalPath('')
|
||||
setPathDirty(false)
|
||||
setCloneStatus('idle')
|
||||
setCloneError(null)
|
||||
previousSuggestedPathRef.current = ''
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [onClose, resetState])
|
||||
|
||||
const handleRepoUrlChange = useCallback((value: string) => {
|
||||
setRepoUrl(value)
|
||||
setCloneError(null)
|
||||
|
||||
const nextSuggestedPath = suggestedPathFromUrl(value)
|
||||
const previousSuggestedPath = previousSuggestedPathRef.current
|
||||
|
||||
if (shouldSyncSuggestedPath(localPath, pathDirty, previousSuggestedPath)) {
|
||||
setLocalPath(nextSuggestedPath)
|
||||
}
|
||||
|
||||
previousSuggestedPathRef.current = nextSuggestedPath
|
||||
}, [localPath, pathDirty])
|
||||
|
||||
const handleLocalPathChange = useCallback((value: string) => {
|
||||
setPathDirty(true)
|
||||
setLocalPath(value)
|
||||
setCloneError(null)
|
||||
}, [])
|
||||
|
||||
const handleClone = useCallback(async () => {
|
||||
const trimmedUrl = repoUrl.trim()
|
||||
const trimmedPath = localPath.trim()
|
||||
if (!trimmedUrl || !trimmedPath) return
|
||||
|
||||
setCloneStatus('cloning')
|
||||
setCloneError(null)
|
||||
|
||||
try {
|
||||
await tauriCall<string>('clone_repo', { url: trimmedUrl, localPath: trimmedPath })
|
||||
onVaultCloned(trimmedPath, labelFromPath(trimmedPath))
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
setCloneStatus('error')
|
||||
setCloneError(`Clone failed: ${String(error)}`)
|
||||
}
|
||||
}, [handleClose, localPath, onVaultCloned, repoUrl])
|
||||
|
||||
return {
|
||||
repoUrl,
|
||||
localPath,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
isCloneDisabled: !repoUrl.trim() || !localPath.trim() || cloneStatus === 'cloning',
|
||||
handleClose,
|
||||
handleRepoUrlChange,
|
||||
handleLocalPathChange,
|
||||
handleClone,
|
||||
}
|
||||
}
|
||||
|
||||
export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModalProps) {
|
||||
const {
|
||||
repoUrl,
|
||||
localPath,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
isCloneDisabled,
|
||||
handleClose,
|
||||
handleRepoUrlChange,
|
||||
handleLocalPathChange,
|
||||
handleClone,
|
||||
} = useCloneVaultForm(onClose, onVaultCloned)
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) handleClose()
|
||||
}, [handleClose])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[520px]" data-testid="clone-vault-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clone Git Repo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Clone any remote repository into a local vault folder. Tolaria uses your existing system git
|
||||
configuration for authentication.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="clone-repo-url">Repository URL</label>
|
||||
<Input
|
||||
id="clone-repo-url"
|
||||
placeholder="git@host:owner/repo.git or https://host/owner/repo.git"
|
||||
value={repoUrl}
|
||||
onChange={(event) => handleRepoUrlChange(event.target.value)}
|
||||
data-testid="clone-repo-url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="clone-vault-path">Clone to</label>
|
||||
<Input
|
||||
id="clone-vault-path"
|
||||
placeholder="~/Vaults/my-vault"
|
||||
value={localPath}
|
||||
onChange={(event) => handleLocalPathChange(event.target.value)}
|
||||
data-testid="clone-vault-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
SSH keys, the git credential manager, `gh auth`, and other system git auth methods all work.
|
||||
</p>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive" data-testid="clone-vault-error">{cloneError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
|
||||
<Button
|
||||
onClick={handleClone}
|
||||
disabled={isCloneDisabled}
|
||||
data-testid="clone-vault-submit"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -48,6 +48,11 @@ describe('ColorSwatch', () => {
|
||||
})
|
||||
|
||||
describe('ColorEditableValue', () => {
|
||||
it('left-aligns the text display in view mode', () => {
|
||||
render(<ColorEditableValue value="#3b82f6" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
|
||||
expect(screen.getByText('#3b82f6')).toHaveClass('text-left')
|
||||
})
|
||||
|
||||
it('shows swatch when value is a valid hex color', () => {
|
||||
render(<ColorEditableValue value="#3b82f6" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
|
||||
expect(screen.getByTestId('color-swatch')).toBeTruthy()
|
||||
|
||||
@@ -102,7 +102,7 @@ export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCa
|
||||
<span className="inline-flex h-6 min-w-0 items-center gap-1.5">
|
||||
{showSwatch && <ColorSwatch color={value} onChange={handlePickerChange} />}
|
||||
<span
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import type { CommandAction } from '../hooks/useCommandRegistry'
|
||||
|
||||
// jsdom doesn't implement scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
vi.mock('../utils/aiPromptBridge', () => ({
|
||||
queueAiPrompt: vi.fn(),
|
||||
requestOpenAiChat: vi.fn(),
|
||||
}))
|
||||
|
||||
const makeCommand = (overrides: Partial<CommandAction> = {}): CommandAction => ({
|
||||
id: 'test-cmd',
|
||||
label: 'Test Command',
|
||||
@@ -25,6 +32,59 @@ const commands: CommandAction[] = [
|
||||
makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }),
|
||||
]
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' }),
|
||||
]
|
||||
|
||||
function setSelection(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const targetNode = editor.firstChild ?? editor
|
||||
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
|
||||
? Math.min(offset, targetNode.textContent?.length ?? 0)
|
||||
: Math.min(offset, targetNode.childNodes.length)
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(targetNode, safeOffset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function updateAiInput(text: string) {
|
||||
const editor = screen.getByTestId('command-palette-ai-input')
|
||||
editor.textContent = text
|
||||
setSelection(editor, text.length)
|
||||
fireEvent.input(editor)
|
||||
return editor
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
@@ -166,6 +226,54 @@ describe('CommandPalette', () => {
|
||||
expect(screen.getByText('esc close')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches into AI mode when the query starts with a leading space', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
|
||||
expect(screen.getByTestId('command-palette-ai-input')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Ask Claude Code').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to command mode when the leading space is deleted', () => {
|
||||
render(
|
||||
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
updateAiInput('new')
|
||||
|
||||
const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement
|
||||
expect(screen.queryByTestId('command-palette-ai-input')).toBeNull()
|
||||
expect(input.value).toBe('new')
|
||||
expect(screen.getByText('New Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('queues a stripped AI prompt and closes on Enter in AI mode', () => {
|
||||
render(
|
||||
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
const editor = updateAiInput(' hello world')
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
expect(queueAiPrompt).toHaveBeenCalledWith('hello world', [])
|
||||
expect(requestOpenAiChat).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes without queueing when AI mode only contains the trigger space', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
||||
fireEvent.keyDown(screen.getByTestId('command-palette-ai-input'), { key: 'Enter' })
|
||||
|
||||
expect(queueAiPrompt).not.toHaveBeenCalled()
|
||||
expect(requestOpenAiChat).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('relevance ranking', () => {
|
||||
const relevanceCommands: CommandAction[] = [
|
||||
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note' }),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { fuzzyMatch } from '../utils/fuzzyMatch'
|
||||
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
|
||||
import { groupSortKey } from '../hooks/useCommandRegistry'
|
||||
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
commands: CommandAction[]
|
||||
entries?: VaultEntry[]
|
||||
claudeCodeReady?: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -15,17 +21,17 @@ interface ScoredCommand {
|
||||
score: number
|
||||
}
|
||||
|
||||
function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null {
|
||||
const labelResult = fuzzyMatch(query, cmd.label)
|
||||
if (labelResult.match) return { command: cmd, score: labelResult.score }
|
||||
function matchCommand(query: string, command: CommandAction): ScoredCommand | null {
|
||||
const labelResult = fuzzyMatch(query, command.label)
|
||||
if (labelResult.match) return { command, score: labelResult.score }
|
||||
|
||||
for (const kw of cmd.keywords ?? []) {
|
||||
const kwResult = fuzzyMatch(query, kw)
|
||||
if (kwResult.match) return { command: cmd, score: kwResult.score - 1 }
|
||||
for (const keyword of command.keywords ?? []) {
|
||||
const keywordResult = fuzzyMatch(query, keyword)
|
||||
if (keywordResult.match) return { command, score: keywordResult.score - 1 }
|
||||
}
|
||||
|
||||
const groupResult = fuzzyMatch(query, cmd.group)
|
||||
if (groupResult.match) return { command: cmd, score: groupResult.score - 2 }
|
||||
const groupResult = fuzzyMatch(query, command.group)
|
||||
if (groupResult.match) return { command, score: groupResult.score - 2 }
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -34,83 +40,262 @@ function groupResults(
|
||||
commands: CommandAction[],
|
||||
byRelevance: boolean,
|
||||
): { group: CommandGroup; items: CommandAction[] }[] {
|
||||
const map = new Map<CommandGroup, CommandAction[]>()
|
||||
for (const cmd of commands) {
|
||||
const list = map.get(cmd.group)
|
||||
if (list) list.push(cmd)
|
||||
else map.set(cmd.group, [cmd])
|
||||
const groupedCommands = new Map<CommandGroup, CommandAction[]>()
|
||||
|
||||
for (const command of commands) {
|
||||
const existing = groupedCommands.get(command.group)
|
||||
if (existing) {
|
||||
existing.push(command)
|
||||
continue
|
||||
}
|
||||
groupedCommands.set(command.group, [command])
|
||||
}
|
||||
const entries = Array.from(map.entries())
|
||||
|
||||
const entries = Array.from(groupedCommands.entries())
|
||||
if (!byRelevance) {
|
||||
entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
|
||||
entries.sort((left, right) => groupSortKey(left[0]) - groupSortKey(right[0]))
|
||||
}
|
||||
|
||||
return entries.map(([group, items]) => ({ group, items }))
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
|
||||
setSelectedIndex(0)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function usePaletteResults(commands: CommandAction[], query: string) {
|
||||
const enabledCommands = useMemo(
|
||||
() => commands.filter(c => c.enabled),
|
||||
() => commands.filter((command) => command.enabled),
|
||||
[commands],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!query.trim()) return enabledCommands
|
||||
return enabledCommands
|
||||
.map(cmd => matchCommand(query, cmd))
|
||||
.filter((r): r is ScoredCommand => r !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(r => r.command)
|
||||
.map((command) => matchCommand(query, command))
|
||||
.filter((result): result is ScoredCommand => result !== null)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.map((result) => result.command)
|
||||
}, [enabledCommands, query])
|
||||
|
||||
const hasQuery = !!query.trim()
|
||||
const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery])
|
||||
const flatList = useMemo(() => groups.flatMap(g => g.items), [groups])
|
||||
const hasQuery = query.trim().length > 0
|
||||
const groups = useMemo(
|
||||
() => groupResults(filteredCommands, hasQuery),
|
||||
[filteredCommands, hasQuery],
|
||||
)
|
||||
|
||||
return {
|
||||
groups,
|
||||
flatList: groups.flatMap((group) => group.items),
|
||||
}
|
||||
}
|
||||
|
||||
function CommandPaletteInput({
|
||||
inputRef,
|
||||
query,
|
||||
onChange,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
query: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteResults({
|
||||
groups,
|
||||
selectedIndex,
|
||||
listRef,
|
||||
onHover,
|
||||
onSelect,
|
||||
}: {
|
||||
groups: { group: CommandGroup; items: CommandAction[] }[]
|
||||
selectedIndex: number
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
onHover: (index: number) => void
|
||||
onSelect: (command: CommandAction) => void
|
||||
}) {
|
||||
const flatList = groups.flatMap((group) => group.items)
|
||||
|
||||
if (flatList.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
No matching commands
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = groups.reduce<Array<{ group: CommandGroup; items: CommandAction[]; startIndex: number }>>(
|
||||
(acc, group) => {
|
||||
const previous = acc.at(-1)
|
||||
acc.push({
|
||||
...group,
|
||||
startIndex: previous ? previous.startIndex + previous.items.length : 0,
|
||||
})
|
||||
return acc
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
{sections.map(({ group, items, startIndex }) => {
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((command, index) => {
|
||||
const globalIndex = startIndex + index
|
||||
return (
|
||||
<CommandRow
|
||||
key={command.id}
|
||||
command={command}
|
||||
selected={globalIndex === selectedIndex}
|
||||
onHover={() => onHover(globalIndex)}
|
||||
onSelect={() => onSelect(command)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteFooter({
|
||||
aiMode,
|
||||
}: {
|
||||
aiMode: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{aiMode ? 'Claude mode' : '↑↓ navigate'}</span>
|
||||
<span>{aiMode ? '↵ send' : '↵ select'}</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, ...props }: CommandPaletteProps) {
|
||||
if (!open) return null
|
||||
return <OpenCommandPalette {...props} />
|
||||
}
|
||||
|
||||
function OpenCommandPalette({
|
||||
commands,
|
||||
entries = [],
|
||||
claudeCodeReady = true,
|
||||
onClose,
|
||||
}: Omit<CommandPaletteProps, 'open'>) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [aiValue, setAiValue] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const aiInputRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const aiMode = aiValue.startsWith(' ')
|
||||
const { groups, flatList } = usePaletteResults(commands, query)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change
|
||||
}, [query])
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
if (aiMode) {
|
||||
aiInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
inputRef.current?.focus()
|
||||
}, 50)
|
||||
return () => window.clearTimeout(focusTimer)
|
||||
}, [aiMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const el = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | undefined
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex])
|
||||
if (aiMode || !listRef.current) return
|
||||
const selectedElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null
|
||||
selectedElement?.scrollIntoView({ block: 'nearest' })
|
||||
}, [aiMode, selectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault(); onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, flatList.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const cmd = flatList[selectedIndex]
|
||||
if (cmd) { onClose(); cmd.execute() }
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (aiMode) return
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((current) => Math.min(current + 1, flatList.length - 1))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((current) => Math.max(current - 1, 0))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const command = flatList[selectedIndex]
|
||||
if (!command) return
|
||||
onClose()
|
||||
command.execute()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [open, flatList, selectedIndex, onClose])
|
||||
|
||||
if (!open) return null
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [aiMode, flatList, onClose, selectedIndex])
|
||||
|
||||
let runningIndex = 0
|
||||
const handleQueryChange = (nextQuery: string) => {
|
||||
setSelectedIndex(0)
|
||||
if (nextQuery.startsWith(' ')) {
|
||||
setAiValue(nextQuery)
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
|
||||
setQuery(nextQuery)
|
||||
}
|
||||
|
||||
const handleAiValueChange = (nextValue: string) => {
|
||||
setSelectedIndex(0)
|
||||
if (nextValue.startsWith(' ')) {
|
||||
setAiValue(nextValue)
|
||||
return
|
||||
}
|
||||
|
||||
setAiValue('')
|
||||
setQuery(nextValue)
|
||||
}
|
||||
|
||||
const handleSelectCommand = (command: CommandAction) => {
|
||||
onClose()
|
||||
command.execute()
|
||||
}
|
||||
|
||||
const handleSubmitAiPrompt = (text: string, references: NoteReference[]) => {
|
||||
if (!text.trim()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (!claudeCodeReady) return
|
||||
|
||||
queueAiPrompt(text, references)
|
||||
requestOpenAiChat()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -118,53 +303,33 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex w-[520px] max-w-[90vw] max-h-[440px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={cn(
|
||||
'flex w-[520px] max-h-[440px] max-w-[90vw] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]',
|
||||
aiMode && 'min-h-[220px]',
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
{flatList.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
No matching commands
|
||||
</div>
|
||||
) : (
|
||||
groups.map(({ group, items }) => {
|
||||
const startIndex = runningIndex
|
||||
runningIndex += items.length
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((cmd, i) => {
|
||||
const globalIdx = startIndex + i
|
||||
return (
|
||||
<CommandRow
|
||||
key={cmd.id}
|
||||
command={cmd}
|
||||
selected={globalIdx === selectedIndex}
|
||||
onHover={() => setSelectedIndex(globalIdx)}
|
||||
onSelect={() => { onClose(); cmd.execute() }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>↑↓ navigate</span>
|
||||
<span>↵ select</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
{aiMode ? (
|
||||
<CommandPaletteAiMode
|
||||
entries={entries}
|
||||
value={aiValue}
|
||||
claudeCodeReady={claudeCodeReady}
|
||||
onChange={handleAiValueChange}
|
||||
onSubmit={handleSubmitAiPrompt}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CommandPaletteInput inputRef={inputRef} query={query} onChange={handleQueryChange} />
|
||||
<CommandPaletteResults
|
||||
groups={groups}
|
||||
selectedIndex={selectedIndex}
|
||||
listRef={listRef}
|
||||
onHover={setSelectedIndex}
|
||||
onSelect={handleSelectCommand}
|
||||
/>
|
||||
<CommandPaletteFooter aiMode={false} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
67
src/components/CommandPaletteAiMode.tsx
Normal file
67
src/components/CommandPaletteAiMode.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Sparkle } from '@phosphor-icons/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { InlineWikilinkInput } from './InlineWikilinkInput'
|
||||
|
||||
interface CommandPaletteAiModeProps {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
claudeCodeReady: boolean
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (text: string, references: NoteReference[]) => void
|
||||
}
|
||||
|
||||
function stripLeadingSpace(value: string): string {
|
||||
return value.startsWith(' ') ? value.slice(1) : value
|
||||
}
|
||||
|
||||
export function CommandPaletteAiMode({
|
||||
entries,
|
||||
value,
|
||||
claudeCodeReady,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: CommandPaletteAiModeProps) {
|
||||
return (
|
||||
<InlineWikilinkInput
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={(text, references) => onSubmit(stripLeadingSpace(text), references)}
|
||||
submitOnEmpty={true}
|
||||
placeholder="Ask Claude Code..."
|
||||
dataTestId="command-palette-ai-input"
|
||||
editorClassName="border-none px-0 py-0 text-[15px]"
|
||||
suggestionListVariant="palette"
|
||||
paletteHeader={(
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<Sparkle size={12} weight="fill" />
|
||||
<span>Ask Claude Code</span>
|
||||
</div>
|
||||
)}
|
||||
paletteEmptyState={(
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
{!claudeCodeReady ? (
|
||||
'Claude Code is not available on this machine.'
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 font-medium text-foreground">Ask Claude Code</div>
|
||||
<div>
|
||||
{value.trim().length === 0
|
||||
? 'Type your prompt after the leading space.'
|
||||
: 'Type [[ to insert a note reference inline.'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
paletteFooter={(
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>Claude mode</span>
|
||||
<span>↵ send</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -129,6 +129,30 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('left-aligns mixed property value displays', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{
|
||||
Owner: 'Luca',
|
||||
History_confidence: 0.84,
|
||||
Date: '2026-04-11',
|
||||
icon: 'rocket',
|
||||
color: '#3b82f6',
|
||||
Window_end: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Luca').parentElement).toHaveClass('justify-start', 'text-left')
|
||||
expect(screen.getByText('0.84').parentElement).toHaveClass('justify-start', 'text-left')
|
||||
expect(screen.getByTestId('date-display')).toHaveClass('text-left')
|
||||
expect(screen.getByTestId('icon-editable-display')).toHaveClass('text-left')
|
||||
expect(screen.getByText('#3b82f6')).toHaveClass('text-left')
|
||||
expect(screen.getByText('\u2014').parentElement).toHaveClass('justify-start', 'text-left')
|
||||
})
|
||||
|
||||
it('hides Owner with wikilink value from Properties panel', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
|
||||
@@ -42,6 +42,11 @@ describe('EditableValue', () => {
|
||||
expect(value).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('left-aligns plain text values in view mode', () => {
|
||||
render(<EditableValue value="Active" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
expect(screen.getByText('Active').parentElement).toHaveClass('justify-start', 'text-left')
|
||||
})
|
||||
|
||||
it('shows input in editing mode', () => {
|
||||
render(<EditableValue value="Active" onSave={onSave} onCancel={onCancel} isEditing={true} onStartEdit={onStartEdit} />)
|
||||
const input = screen.getByDisplayValue('Active')
|
||||
@@ -238,6 +243,11 @@ describe('UrlValue', () => {
|
||||
expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com')
|
||||
})
|
||||
|
||||
it('left-aligns URL values in view mode', () => {
|
||||
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
expect(screen.getByTestId('url-link')).toHaveClass('justify-start', 'text-left')
|
||||
})
|
||||
|
||||
it('opens URL via openExternalUrl on click', () => {
|
||||
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
fireEvent.click(screen.getByTestId('url-link'))
|
||||
|
||||
@@ -66,7 +66,7 @@ export function UrlValue({
|
||||
return (
|
||||
<span className="group/url flex w-full min-w-0 items-center gap-1">
|
||||
<span
|
||||
className="inline-flex h-6 min-w-0 flex-1 cursor-pointer items-center justify-end overflow-hidden rounded-md px-2 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
|
||||
className="inline-flex h-6 min-w-0 flex-1 cursor-pointer items-center justify-start overflow-hidden rounded-md px-2 text-left text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
|
||||
onClick={handleOpen}
|
||||
title={value}
|
||||
data-testid="url-link"
|
||||
@@ -125,7 +125,7 @@ export function EditableValue({
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-6 w-full min-w-0 cursor-pointer items-center justify-end overflow-hidden rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
className="inline-flex h-6 w-full min-w-0 cursor-pointer items-center justify-start overflow-hidden rounded-md px-2 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -68,6 +68,12 @@
|
||||
color: var(--headings-h1-color);
|
||||
letter-spacing: var(--headings-h1-letter-spacing);
|
||||
}
|
||||
.editor__blocknote-container [data-content-type="heading"]:not([data-level])[data-is-empty-and-focused] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before,
|
||||
.editor__blocknote-container [data-content-type="heading"]:not([data-level])[data-is-only-empty-block] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before,
|
||||
.editor__blocknote-container [data-content-type="heading"][data-level="1"][data-is-empty-and-focused] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before,
|
||||
.editor__blocknote-container [data-content-type="heading"][data-level="1"][data-is-only-empty-block] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
content: "Title" !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-block-outer:has(> .bn-block > [data-content-type="heading"][data-level="2"]) {
|
||||
margin-top: var(--headings-h2-margin-top) !important;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FeedbackDialog } from './FeedbackDialog'
|
||||
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
|
||||
import { TOLARIA_GITHUB_ISSUES_URL } from '../constants/feedback'
|
||||
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -32,7 +32,7 @@ describe('FeedbackDialog', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to Issues' }))
|
||||
|
||||
await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(LAPUTA_GITHUB_ISSUES_URL))
|
||||
await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(TOLARIA_GITHUB_ISSUES_URL))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
|
||||
import { TOLARIA_GITHUB_ISSUES_URL } from '../constants/feedback'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
interface FeedbackDialogProps {
|
||||
@@ -18,7 +18,7 @@ interface FeedbackDialogProps {
|
||||
|
||||
export function FeedbackDialog({ open, onClose }: FeedbackDialogProps) {
|
||||
const handleOpenIssues = () => {
|
||||
void openExternalUrl(LAPUTA_GITHUB_ISSUES_URL)
|
||||
void openExternalUrl(TOLARIA_GITHUB_ISSUES_URL)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { GithubLogo, CircleNotch, ArrowClockwise, Copy, Check } from '@phosphor-icons/react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import type { DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
type OAuthStatus = 'idle' | 'waiting' | 'error'
|
||||
|
||||
/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */
|
||||
function processPollResult(
|
||||
result: DeviceFlowPollResult,
|
||||
callbacks: {
|
||||
onComplete: (token: string) => Promise<void>
|
||||
onExpired: () => void
|
||||
onError: (msg: string) => void
|
||||
},
|
||||
): 'done' | 'continue' {
|
||||
if (result.status === 'complete' && result.access_token) {
|
||||
callbacks.onComplete(result.access_token)
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
callbacks.onExpired()
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
callbacks.onError(result.error ?? 'Authorization failed.')
|
||||
return 'done'
|
||||
}
|
||||
return 'continue'
|
||||
}
|
||||
|
||||
interface GitHubDeviceFlowProps {
|
||||
onConnected: (token: string, username: string) => void
|
||||
}
|
||||
|
||||
export function GitHubDeviceFlow({ onConnected }: GitHubDeviceFlowProps) {
|
||||
const [oauthStatus, setOauthStatus] = useState<OAuthStatus>('idle')
|
||||
const [userCode, setUserCode] = useState<string | null>(null)
|
||||
const [verificationUri, setVerificationUri] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const pollingRef = useRef(false)
|
||||
const deviceCodeRef = useRef<string | null>(null)
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
pollingRef.current = false
|
||||
deviceCodeRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { pollingRef.current = false }
|
||||
}, [])
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setOauthStatus('waiting')
|
||||
setErrorMessage(null)
|
||||
setUserCode(null)
|
||||
|
||||
try {
|
||||
const flowStart = await tauriCall<DeviceFlowStart>('github_device_flow_start')
|
||||
setUserCode(flowStart.user_code)
|
||||
setVerificationUri(flowStart.verification_uri)
|
||||
deviceCodeRef.current = flowStart.device_code
|
||||
openExternalUrl(flowStart.verification_uri).catch(() => {})
|
||||
|
||||
pollingRef.current = true
|
||||
const intervalMs = Math.max(flowStart.interval * 1000, 5000)
|
||||
|
||||
const pollLoop = async () => {
|
||||
while (pollingRef.current && deviceCodeRef.current) {
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
if (!pollingRef.current) break
|
||||
|
||||
const result = await tauriCall<DeviceFlowPollResult>('github_device_flow_poll', {
|
||||
deviceCode: deviceCodeRef.current,
|
||||
})
|
||||
const outcome = processPollResult(result, {
|
||||
onComplete: async (token) => {
|
||||
const user = await tauriCall<GitHubUser>('github_get_user', { token })
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
onConnected(token, user.login)
|
||||
},
|
||||
onExpired: () => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage('Authorization expired. Please try again.')
|
||||
},
|
||||
onError: (msg) => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(msg)
|
||||
},
|
||||
})
|
||||
if (outcome === 'done') return
|
||||
}
|
||||
}
|
||||
|
||||
pollLoop().catch(err => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.')
|
||||
})
|
||||
} catch (err) {
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.')
|
||||
}
|
||||
}, [onConnected, stopPolling])
|
||||
|
||||
const resetOAuth = useCallback(() => {
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
setVerificationUri(null)
|
||||
setErrorMessage(null)
|
||||
}, [stopPolling])
|
||||
|
||||
if (oauthStatus === 'waiting' && userCode) {
|
||||
return <DeviceCodeView userCode={userCode} verificationUri={verificationUri} onCancel={resetOAuth} />
|
||||
}
|
||||
|
||||
return <LoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} onRetry={errorMessage ? () => { resetOAuth(); handleLogin() } : undefined} />
|
||||
}
|
||||
|
||||
function DeviceCodeView({ userCode, verificationUri, onCancel }: { userCode: string; verificationUri: string | null; onCancel: () => void }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopyCode = useCallback(() => {
|
||||
navigator.clipboard.writeText(userCode).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {})
|
||||
}, [userCode])
|
||||
|
||||
const handleOpenUrl = useCallback(() => {
|
||||
if (verificationUri) openExternalUrl(verificationUri).catch(() => {})
|
||||
}, [verificationUri])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} data-testid="github-waiting">
|
||||
<div
|
||||
className="border border-border rounded px-4 py-3"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 8, textAlign: 'center' }}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Enter this code on GitHub:</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
style={{ fontSize: 24, fontWeight: 700, letterSpacing: 4, color: 'var(--foreground)', fontFamily: 'monospace' }}
|
||||
data-testid="github-user-code"
|
||||
>
|
||||
{userCode}
|
||||
</div>
|
||||
<button
|
||||
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={handleCopyCode}
|
||||
title="Copy code"
|
||||
data-testid="github-copy-code"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{verificationUri && (
|
||||
<button
|
||||
className="border-none bg-transparent text-muted-foreground cursor-pointer hover:text-foreground underline"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={handleOpenUrl}
|
||||
data-testid="github-open-url"
|
||||
>
|
||||
{verificationUri}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-2" style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>
|
||||
<CircleNotch size={14} className="animate-spin" />
|
||||
Waiting for authorization...
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', alignSelf: 'center' }}
|
||||
onClick={onCancel}
|
||||
data-testid="github-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
className="border-none rounded cursor-pointer flex items-center justify-center gap-2"
|
||||
style={{ fontSize: 13, fontWeight: 500, padding: '8px 16px', background: 'var(--foreground)', color: 'var(--background)', height: 36 }}
|
||||
onClick={onLogin}
|
||||
disabled={disabled}
|
||||
data-testid="github-login"
|
||||
>
|
||||
<GithubLogo size={16} weight="fill" />
|
||||
Login with GitHub
|
||||
</button>
|
||||
{errorMessage && (
|
||||
<div className="flex items-center gap-2" style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)' }}>
|
||||
<span data-testid="github-error">{errorMessage}</span>
|
||||
{onRetry && (
|
||||
<button
|
||||
className="border-none bg-transparent cursor-pointer hover:text-foreground flex items-center gap-1"
|
||||
style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)', padding: 0 }}
|
||||
onClick={onRetry}
|
||||
data-testid="github-retry"
|
||||
>
|
||||
<ArrowClockwise size={12} />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { GitHubVaultModal } from './GitHubVaultModal'
|
||||
|
||||
// Mock mockInvoke — the component uses tauriCall which calls mockInvoke in browser
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
const mockInvokeFn = vi.mocked(mockInvoke)
|
||||
|
||||
const MOCK_REPOS = [
|
||||
{ name: 'my-vault', full_name: 'user/my-vault', description: 'A personal vault', private: true, clone_url: 'https://github.com/user/my-vault.git', html_url: 'https://github.com/user/my-vault', updated_at: '2026-02-20T10:00:00Z' },
|
||||
{ name: 'public-notes', full_name: 'user/public-notes', description: 'Public notes repo', private: false, clone_url: 'https://github.com/user/public-notes.git', html_url: 'https://github.com/user/public-notes', updated_at: '2026-02-19T10:00:00Z' },
|
||||
]
|
||||
|
||||
describe('GitHubVaultModal', () => {
|
||||
const onClose = vi.fn()
|
||||
const onVaultCloned = vi.fn()
|
||||
const onOpenSettings = vi.fn()
|
||||
const onGitHubConnected = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_list_repos') return MOCK_REPOS
|
||||
if (cmd === 'github_create_repo') return MOCK_REPOS[0]
|
||||
if (cmd === 'clone_repo') return 'Cloned successfully'
|
||||
throw new Error(`Unknown command: ${cmd}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders nothing when not open', () => {
|
||||
const { container } = render(
|
||||
<GitHubVaultModal open={false} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
expect(container.querySelector('[data-testid="github-vault-modal"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows settings fallback when no token and no onGitHubConnected', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
expect(screen.getByText(/Add your GitHub token in Settings/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('github-open-settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens settings when "Open Settings" clicked without token', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('github-open-settings'))
|
||||
expect(onOpenSettings).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows inline device flow when no token and onGitHubConnected is provided', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
|
||||
)
|
||||
expect(screen.getByTestId('github-login')).toBeInTheDocument()
|
||||
expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('github-open-settings')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows device code after starting inline OAuth flow', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_device_flow_start') {
|
||||
return { device_code: 'dc_modal', user_code: 'MODAL-5678', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }
|
||||
}
|
||||
if (cmd === 'github_device_flow_poll') {
|
||||
return { status: 'pending', access_token: null, error: 'authorization_pending' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('github-user-code')).toHaveTextContent('MODAL-5678')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows clone and create tabs when token is present', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
expect(screen.getByTestId('github-tab-clone')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('github-tab-create')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads and displays repos in clone tab', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('user/public-notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('A personal vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters repos by search', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('github-repo-search'), { target: { value: 'public' } })
|
||||
|
||||
expect(screen.queryByText('user/my-vault')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('user/public-notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects a repo and auto-fills clone path', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
|
||||
|
||||
const pathInput = screen.getByTestId('github-clone-path') as HTMLInputElement
|
||||
expect(pathInput.value).toBe('~/Vaults/my-vault')
|
||||
})
|
||||
|
||||
it('clone button disabled without selection', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('github-clone-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('github-clone-btn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls clone_repo and onVaultCloned on clone', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
|
||||
fireEvent.click(screen.getByTestId('github-clone-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault')
|
||||
})
|
||||
})
|
||||
|
||||
it('has create tab trigger that is clickable', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
const createTab = screen.getByTestId('github-tab-create')
|
||||
expect(createTab).toBeInTheDocument()
|
||||
expect(createTab).toHaveTextContent('Create New')
|
||||
expect(createTab).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows error when clone fails', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_list_repos') return MOCK_REPOS
|
||||
if (cmd === 'clone_repo') throw new Error('Permission denied')
|
||||
throw new Error(`Unknown: ${cmd}`)
|
||||
})
|
||||
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('repo-item-my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('repo-item-my-vault'))
|
||||
fireEvent.click(screen.getByTestId('github-clone-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Clone failed/i)).toBeInTheDocument()
|
||||
})
|
||||
expect(onVaultCloned).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows Private/Public badges on repos', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('user/my-vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Private')).toBeInTheDocument()
|
||||
expect(screen.getByText('Public')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,444 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { GithubRepo } from '../types'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
interface GitHubVaultModalProps {
|
||||
open: boolean
|
||||
githubToken: string | null
|
||||
onClose: () => void
|
||||
onVaultCloned: (path: string, label: string) => void
|
||||
onOpenSettings: () => void
|
||||
onGitHubConnected?: (token: string, username: string) => void
|
||||
}
|
||||
|
||||
type CloneStatus = 'idle' | 'cloning' | 'success' | 'error'
|
||||
|
||||
function RepoListItem({ repo, selected, onSelect }: { repo: GithubRepo; selected: boolean; onSelect: () => void }) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }}
|
||||
className={`flex flex-col gap-1 rounded-md px-3 py-2.5 cursor-pointer transition-colors ${
|
||||
selected ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
data-testid={`repo-item-${repo.name}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{repo.full_name}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{repo.private ? 'Private' : 'Public'}
|
||||
</Badge>
|
||||
</div>
|
||||
{repo.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{repo.description}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CloneTab({
|
||||
repos,
|
||||
loading,
|
||||
search,
|
||||
setSearch,
|
||||
selectedRepo,
|
||||
setSelectedRepo,
|
||||
localPath,
|
||||
setLocalPath,
|
||||
onClone,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
}: {
|
||||
repos: GithubRepo[]
|
||||
loading: boolean
|
||||
search: string
|
||||
setSearch: (v: string) => void
|
||||
selectedRepo: GithubRepo | null
|
||||
setSelectedRepo: (r: GithubRepo) => void
|
||||
localPath: string
|
||||
setLocalPath: (v: string) => void
|
||||
onClone: () => void
|
||||
cloneStatus: CloneStatus
|
||||
cloneError: string | null
|
||||
}) {
|
||||
const filtered = repos.filter(r =>
|
||||
r.full_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(r.description?.toLowerCase().includes(search.toLowerCase()) ?? false)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 min-h-0">
|
||||
<Input
|
||||
placeholder="Search repos or paste URL..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
data-testid="github-repo-search"
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border border-border rounded-md min-h-[180px] max-h-[240px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
Loading repositories...
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
{search ? 'No repos match your search' : 'No repositories found'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 p-1">
|
||||
{filtered.map(repo => (
|
||||
<RepoListItem
|
||||
key={repo.full_name}
|
||||
repo={repo}
|
||||
selected={selectedRepo?.full_name === repo.full_name}
|
||||
onSelect={() => setSelectedRepo(repo)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">Clone to:</label>
|
||||
<Input
|
||||
value={localPath}
|
||||
onChange={e => setLocalPath(e.target.value)}
|
||||
placeholder="~/Vaults/repo-name"
|
||||
data-testid="github-clone-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive">{cloneError}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{filtered.length} repo{filtered.length !== 1 ? 's' : ''} found
|
||||
</span>
|
||||
<Button
|
||||
onClick={onClone}
|
||||
disabled={!selectedRepo || !localPath.trim() || cloneStatus === 'cloning'}
|
||||
data-testid="github-clone-btn"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTab({
|
||||
repoName,
|
||||
setRepoName,
|
||||
isPrivate,
|
||||
setIsPrivate,
|
||||
localPath,
|
||||
setLocalPath,
|
||||
onCreate,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
}: {
|
||||
repoName: string
|
||||
setRepoName: (v: string) => void
|
||||
isPrivate: boolean
|
||||
setIsPrivate: (v: boolean) => void
|
||||
localPath: string
|
||||
setLocalPath: (v: string) => void
|
||||
onCreate: () => void
|
||||
cloneStatus: CloneStatus
|
||||
cloneError: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">Repository name</label>
|
||||
<Input
|
||||
value={repoName}
|
||||
onChange={e => setRepoName(e.target.value)}
|
||||
placeholder="my-vault"
|
||||
data-testid="github-repo-name"
|
||||
/>
|
||||
{repoName && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
github.com/you/{repoName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-medium text-foreground">Visibility</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPrivate(true)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border transition-colors ${
|
||||
isPrivate
|
||||
? 'border-ring bg-accent text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
data-testid="github-visibility-private"
|
||||
>
|
||||
Private
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPrivate(false)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border transition-colors ${
|
||||
!isPrivate
|
||||
? 'border-ring bg-accent text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
data-testid="github-visibility-public"
|
||||
>
|
||||
Public
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">Clone to:</label>
|
||||
<Input
|
||||
value={localPath}
|
||||
onChange={e => setLocalPath(e.target.value)}
|
||||
placeholder="~/Vaults/my-vault"
|
||||
data-testid="github-create-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive">{cloneError}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
|
||||
<Button
|
||||
onClick={onCreate}
|
||||
disabled={!repoName.trim() || !localPath.trim() || cloneStatus === 'cloning'}
|
||||
data-testid="github-create-btn"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Creating...' : 'Create & Clone'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CloningProgress({ repoName }: { repoName: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8" data-testid="clone-progress">
|
||||
<div className="h-10 w-10 rounded-full border-[3px] border-ring border-t-transparent animate-spin" />
|
||||
<p className="text-sm font-medium text-foreground">Cloning {repoName}...</p>
|
||||
<p className="text-xs text-muted-foreground">This may take a moment for large repositories</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings, onGitHubConnected }: GitHubVaultModalProps) {
|
||||
const [tab, setTab] = useState('clone')
|
||||
const [repos, setRepos] = useState<GithubRepo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedRepo, setSelectedRepo] = useState<GithubRepo | null>(null)
|
||||
const [clonePath, setClonePath] = useState('')
|
||||
const [repoName, setRepoName] = useState('')
|
||||
const [isPrivate, setIsPrivate] = useState(true)
|
||||
const [createPath, setCreatePath] = useState('')
|
||||
const [cloneStatus, setCloneStatus] = useState<CloneStatus>('idle')
|
||||
const [cloneError, setCloneError] = useState<string | null>(null)
|
||||
const loadedRef = useRef(false)
|
||||
|
||||
const loadRepos = useCallback(async () => {
|
||||
if (!githubToken) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await tauriCall<GithubRepo[]>('github_list_repos', { token: githubToken })
|
||||
setRepos(result)
|
||||
} catch (err) {
|
||||
console.error('Failed to load GitHub repos:', err)
|
||||
setCloneError(`Failed to load repos: ${err}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [githubToken])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && githubToken && !loadedRef.current) {
|
||||
loadedRef.current = true
|
||||
loadRepos()
|
||||
}
|
||||
if (!open) {
|
||||
loadedRef.current = false
|
||||
}
|
||||
}, [open, githubToken, loadRepos])
|
||||
|
||||
// Auto-fill clone path when repo selected
|
||||
useEffect(() => {
|
||||
if (selectedRepo) {
|
||||
setClonePath(`~/Vaults/${selectedRepo.name}`)
|
||||
}
|
||||
}, [selectedRepo])
|
||||
|
||||
// Auto-fill create path when name changes
|
||||
useEffect(() => {
|
||||
if (repoName) {
|
||||
setCreatePath(`~/Vaults/${repoName}`)
|
||||
}
|
||||
}, [repoName])
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setTab('clone')
|
||||
setSearch('')
|
||||
setSelectedRepo(null)
|
||||
setClonePath('')
|
||||
setRepoName('')
|
||||
setIsPrivate(true)
|
||||
setCreatePath('')
|
||||
setCloneStatus('idle')
|
||||
setCloneError(null)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [onClose, resetState])
|
||||
|
||||
const handleClone = useCallback(async () => {
|
||||
if (!selectedRepo || !clonePath.trim() || !githubToken) return
|
||||
setCloneStatus('cloning')
|
||||
setCloneError(null)
|
||||
try {
|
||||
await tauriCall<string>('clone_repo', {
|
||||
url: selectedRepo.clone_url,
|
||||
token: githubToken,
|
||||
localPath: clonePath.trim(),
|
||||
})
|
||||
setCloneStatus('success')
|
||||
onVaultCloned(clonePath.trim(), selectedRepo.name)
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
setCloneStatus('error')
|
||||
setCloneError(`Clone failed: ${err}`)
|
||||
}
|
||||
}, [selectedRepo, clonePath, githubToken, onVaultCloned, handleClose])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!repoName.trim() || !createPath.trim() || !githubToken) return
|
||||
setCloneStatus('cloning')
|
||||
setCloneError(null)
|
||||
try {
|
||||
const repo = await tauriCall<GithubRepo>('github_create_repo', {
|
||||
token: githubToken,
|
||||
name: repoName.trim(),
|
||||
private: isPrivate,
|
||||
})
|
||||
await tauriCall<string>('clone_repo', {
|
||||
url: repo.clone_url,
|
||||
token: githubToken,
|
||||
localPath: createPath.trim(),
|
||||
})
|
||||
setCloneStatus('success')
|
||||
onVaultCloned(createPath.trim(), repo.name)
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
setCloneStatus('error')
|
||||
setCloneError(`${err}`)
|
||||
}
|
||||
}, [repoName, createPath, githubToken, isPrivate, onVaultCloned, handleClose])
|
||||
|
||||
if (!githubToken) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isOpen => { if (!isOpen) handleClose() }}>
|
||||
<DialogContent className="sm:max-w-[480px]" data-testid="github-vault-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect GitHub Repo</DialogTitle>
|
||||
<DialogDescription>Connect your GitHub account to clone or create vaults backed by GitHub repos.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
{onGitHubConnected ? (
|
||||
<GitHubDeviceFlow onConnected={onGitHubConnected} />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
You need to connect your GitHub account first. Add your GitHub token in Settings.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => { handleClose(); onOpenSettings() }}
|
||||
data-testid="github-open-settings"
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const cloningRepoName = tab === 'clone' ? (selectedRepo?.full_name ?? '') : repoName
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isOpen => { if (!isOpen) handleClose() }}>
|
||||
<DialogContent className="sm:max-w-[540px]" data-testid="github-vault-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect GitHub Repo</DialogTitle>
|
||||
<DialogDescription>Clone an existing repo or create a new one as a vault.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{cloneStatus === 'cloning' ? (
|
||||
<CloningProgress repoName={cloningRepoName} />
|
||||
) : (
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="clone" data-testid="github-tab-clone">Clone Existing</TabsTrigger>
|
||||
<TabsTrigger value="create" data-testid="github-tab-create">Create New</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="clone">
|
||||
<CloneTab
|
||||
repos={repos}
|
||||
loading={loading}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
selectedRepo={selectedRepo}
|
||||
setSelectedRepo={setSelectedRepo}
|
||||
localPath={clonePath}
|
||||
setLocalPath={setClonePath}
|
||||
onClone={handleClone}
|
||||
cloneStatus={cloneStatus}
|
||||
cloneError={cloneError}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="create">
|
||||
<CreateTab
|
||||
repoName={repoName}
|
||||
setRepoName={setRepoName}
|
||||
isPrivate={isPrivate}
|
||||
setIsPrivate={setIsPrivate}
|
||||
localPath={createPath}
|
||||
setLocalPath={setCreatePath}
|
||||
onCreate={handleCreate}
|
||||
cloneStatus={cloneStatus}
|
||||
cloneError={cloneError}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
|
||||
<GitBranch size={36} className="text-muted-foreground" />
|
||||
<h2 className="m-0 text-lg font-semibold text-foreground">Git repository required</h2>
|
||||
<p className="m-0 text-center text-[13px] leading-relaxed text-muted-foreground">
|
||||
Laputa uses a git repository to track changes, detect moved files, and keep your vault safe.
|
||||
Tolaria uses a git repository to track changes, detect moved files, and keep your vault safe.
|
||||
We'll create a local repo — no remote needed.
|
||||
</p>
|
||||
{error && (
|
||||
|
||||
@@ -22,6 +22,20 @@ function renderIconValue(overrides: Partial<React.ComponentProps<typeof IconEdit
|
||||
}
|
||||
|
||||
describe('IconEditableValue', () => {
|
||||
it('left-aligns the icon display in view mode', () => {
|
||||
render(
|
||||
<IconEditableValue
|
||||
value="rocket"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
isEditing={false}
|
||||
onStartEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('icon-editable-display')).toHaveClass('text-left')
|
||||
})
|
||||
|
||||
it('shows searchable icon results with previews while editing', () => {
|
||||
renderIconValue()
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export function IconEditableValue({
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
data-testid="icon-editable-display"
|
||||
|
||||
198
src/components/InlineWikilinkInput.tsx
Normal file
198
src/components/InlineWikilinkInput.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import {
|
||||
buildInlineWikilinkSegments,
|
||||
extractInlineWikilinkReferences,
|
||||
findActiveWikilinkQuery,
|
||||
findInlineChipDeletionRange,
|
||||
} from './inlineWikilinkText'
|
||||
import {
|
||||
InlineWikilinkEditorField,
|
||||
InlineWikilinkPaletteLayout,
|
||||
InlineWikilinkSuggestionList,
|
||||
} from './InlineWikilinkParts'
|
||||
import { handleInlineWikilinkKeyDown } from './inlineWikilinkKeydown'
|
||||
import { useInlineWikilinkSelection } from './useInlineWikilinkSelection'
|
||||
import { useInlineWikilinkSuggestionsState } from './useInlineWikilinkSuggestionsState'
|
||||
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
|
||||
|
||||
interface InlineWikilinkInputProps {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit?: (text: string, references: NoteReference[]) => void
|
||||
submitOnEmpty?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
dataTestId?: string
|
||||
editorClassName?: string
|
||||
suggestionListVariant?: 'floating' | 'palette'
|
||||
suggestionEmptyLabel?: string
|
||||
paletteHeader?: ReactNode
|
||||
paletteEmptyState?: ReactNode
|
||||
paletteFooter?: ReactNode
|
||||
}
|
||||
|
||||
function deleteInlineChip({
|
||||
direction,
|
||||
segments,
|
||||
selectionIndex,
|
||||
value,
|
||||
onChange,
|
||||
onSelectionIndexChange,
|
||||
}: {
|
||||
direction: 'backward' | 'forward'
|
||||
segments: ReturnType<typeof buildInlineWikilinkSegments>
|
||||
selectionIndex: number
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSelectionIndexChange: (selectionIndex: number) => void
|
||||
}) {
|
||||
const deletionRange = findInlineChipDeletionRange(segments, selectionIndex, direction)
|
||||
if (!deletionRange) return false
|
||||
|
||||
onChange(value.slice(0, deletionRange.start) + value.slice(deletionRange.end))
|
||||
onSelectionIndexChange(deletionRange.start)
|
||||
return true
|
||||
}
|
||||
|
||||
function submitInlineValue({
|
||||
onSubmit,
|
||||
submitOnEmpty,
|
||||
value,
|
||||
references,
|
||||
}: {
|
||||
onSubmit?: (text: string, references: NoteReference[]) => void
|
||||
submitOnEmpty: boolean
|
||||
value: string
|
||||
references: NoteReference[]
|
||||
}) {
|
||||
if (!onSubmit) return
|
||||
const normalizedValue = normalizeInlineWikilinkValue(value)
|
||||
if (!submitOnEmpty && !normalizedValue.trim()) return
|
||||
onSubmit(normalizedValue, references)
|
||||
}
|
||||
|
||||
export function InlineWikilinkInput({
|
||||
entries,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
submitOnEmpty = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
inputRef,
|
||||
dataTestId = 'agent-input',
|
||||
editorClassName,
|
||||
suggestionListVariant = 'floating',
|
||||
suggestionEmptyLabel = 'No matching notes',
|
||||
paletteHeader,
|
||||
paletteEmptyState,
|
||||
paletteFooter,
|
||||
}: InlineWikilinkInputProps) {
|
||||
const segments = useMemo(
|
||||
() => buildInlineWikilinkSegments(value, entries),
|
||||
[entries, value],
|
||||
)
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const {
|
||||
selectionIndex,
|
||||
setSelectionIndex,
|
||||
setCombinedRef,
|
||||
syncSelectionIndex,
|
||||
commitValueFromEditor,
|
||||
focusSelectionAt,
|
||||
} = useInlineWikilinkSelection({
|
||||
value,
|
||||
onChange,
|
||||
inputRef,
|
||||
})
|
||||
const activeQuery = useMemo(
|
||||
() => findActiveWikilinkQuery(value, selectionIndex),
|
||||
[selectionIndex, value],
|
||||
)
|
||||
const references = useMemo(() => extractInlineWikilinkReferences(value, entries), [entries, value])
|
||||
const {
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
setSuggestionIndex,
|
||||
selectSuggestion,
|
||||
cycleSuggestions,
|
||||
} = useInlineWikilinkSuggestionsState({
|
||||
activeQueryKey: activeQuery ? `${activeQuery.start}:${activeQuery.query}` : '',
|
||||
entries,
|
||||
query: activeQuery?.query ?? null,
|
||||
value,
|
||||
selectionIndex,
|
||||
onChange,
|
||||
onSelectionIndexChange: setSelectionIndex,
|
||||
focusSelectionAt,
|
||||
})
|
||||
const deleteAdjacentChip = (direction: 'backward' | 'forward') => {
|
||||
return deleteInlineChip({
|
||||
direction,
|
||||
segments,
|
||||
selectionIndex,
|
||||
value,
|
||||
onChange,
|
||||
onSelectionIndexChange: setSelectionIndex,
|
||||
})
|
||||
}
|
||||
const submitValue = () =>
|
||||
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
handleInlineWikilinkKeyDown({
|
||||
event,
|
||||
disabled,
|
||||
suggestionsOpen: suggestions.length > 0,
|
||||
onCycleSuggestions: cycleSuggestions,
|
||||
onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex),
|
||||
onDeleteAdjacentChip: deleteAdjacentChip,
|
||||
canSubmit: onSubmit !== undefined,
|
||||
onSubmit: submitValue,
|
||||
})
|
||||
const editor = (
|
||||
<InlineWikilinkEditorField
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
inputRef={setCombinedRef}
|
||||
dataTestId={dataTestId}
|
||||
editorClassName={editorClassName}
|
||||
onInput={commitValueFromEditor}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSelectionChange={syncSelectionIndex}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
const suggestionList = suggestions.length > 0 ? (
|
||||
<InlineWikilinkSuggestionList
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selectedSuggestionIndex}
|
||||
onHover={setSuggestionIndex}
|
||||
onSelect={selectSuggestion}
|
||||
typeEntryMap={typeEntryMap}
|
||||
variant={suggestionListVariant}
|
||||
emptyLabel={suggestionEmptyLabel}
|
||||
/>
|
||||
) : null
|
||||
if (suggestionListVariant === 'palette') {
|
||||
return (
|
||||
<InlineWikilinkPaletteLayout
|
||||
header={paletteHeader}
|
||||
editor={editor}
|
||||
suggestionList={suggestionList}
|
||||
emptyState={paletteEmptyState}
|
||||
footer={paletteFooter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div className="relative">{editor}{suggestionList}</div>
|
||||
}
|
||||
251
src/components/InlineWikilinkParts.tsx
Normal file
251
src/components/InlineWikilinkParts.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { createElement } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { getTypeIcon } from './note-item/typeIcon'
|
||||
import type {
|
||||
InlineWikilinkChip,
|
||||
InlineWikilinkSegment,
|
||||
} from './inlineWikilinkText'
|
||||
import type { InlineWikilinkSuggestion } from './inlineWikilinkSuggestions'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function InlineWikilinkChipView({
|
||||
chip,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
chip: InlineWikilinkChip
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const typeEntry = chip.entry.isA ? typeEntryMap[chip.entry.isA] : undefined
|
||||
const color = getTypeColor(chip.entry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(chip.entry.isA, typeEntry?.color)
|
||||
const typeIcon = getTypeIcon(chip.entry.isA, typeEntry?.icon)
|
||||
|
||||
return (
|
||||
<span
|
||||
contentEditable={false}
|
||||
data-chip-target={chip.target}
|
||||
data-testid="inline-wikilink-chip"
|
||||
className="mx-[1px] inline-flex max-w-full items-center gap-1 rounded-full align-baseline"
|
||||
style={{
|
||||
backgroundColor,
|
||||
color,
|
||||
padding: '1px 8px 1px 6px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{chip.entry.icon ? (
|
||||
<NoteTitleIcon icon={chip.entry.icon} size={11} color={color} />
|
||||
) : (
|
||||
createElement(typeIcon, {
|
||||
'aria-hidden': true,
|
||||
width: 11,
|
||||
height: 11,
|
||||
className: 'shrink-0',
|
||||
})
|
||||
)}
|
||||
<span className="truncate">{chip.entry.title}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineSuggestionRow({
|
||||
suggestion,
|
||||
selected,
|
||||
onHover,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
suggestion: InlineWikilinkSuggestion
|
||||
selected: boolean
|
||||
onHover: () => void
|
||||
onSelect: () => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const typeEntry = suggestion.entry.isA ? typeEntryMap[suggestion.entry.isA] : undefined
|
||||
const color = getTypeColor(suggestion.entry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(suggestion.entry.isA, typeEntry?.color)
|
||||
const typeIcon = getTypeIcon(suggestion.entry.isA, typeEntry?.icon)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-1 flex cursor-pointer items-center justify-between rounded-md px-3 py-2 transition-colors',
|
||||
selected ? 'bg-accent' : 'hover:bg-secondary',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onHover}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor, color }}
|
||||
>
|
||||
{suggestion.entry.icon ? (
|
||||
<NoteTitleIcon icon={suggestion.entry.icon} size={11} color={color} />
|
||||
) : (
|
||||
createElement(typeIcon, {
|
||||
'aria-hidden': true,
|
||||
width: 11,
|
||||
height: 11,
|
||||
className: 'shrink-0',
|
||||
})
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-foreground">{suggestion.title}</span>
|
||||
</div>
|
||||
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
|
||||
{suggestion.entry.isA ?? 'Note'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkSuggestionList({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onHover,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
variant = 'floating',
|
||||
emptyLabel = 'No matching notes',
|
||||
}: {
|
||||
suggestions: InlineWikilinkSuggestion[]
|
||||
selectedIndex: number
|
||||
onHover: (index: number) => void
|
||||
onSelect: (index: number) => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
variant?: 'floating' | 'palette'
|
||||
emptyLabel?: string
|
||||
}) {
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-5 text-center text-[13px] text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={variant === 'floating'
|
||||
? 'absolute bottom-full left-0 right-0 z-10 mb-1 max-h-64 overflow-y-auto rounded-lg border border-border bg-popover py-1 shadow-lg'
|
||||
: 'py-1'}
|
||||
data-testid="wikilink-menu"
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<InlineSuggestionRow
|
||||
key={`${suggestion.entry.path}:${suggestion.target}`}
|
||||
suggestion={suggestion}
|
||||
selected={index === selectedIndex}
|
||||
onHover={() => onHover(index)}
|
||||
onSelect={() => onSelect(index)}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkEditorField({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
inputRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
value: string
|
||||
placeholder?: string
|
||||
disabled: boolean
|
||||
inputRef: React.Ref<HTMLDivElement>
|
||||
dataTestId: string
|
||||
editorClassName?: string
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: InlineWikilinkSegment[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{value.length === 0 && placeholder && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-muted-foreground"
|
||||
style={{ padding: '8px 10px', fontSize: 13 }}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={inputRef}
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning={true}
|
||||
role="textbox"
|
||||
aria-multiline="false"
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-placeholder={placeholder}
|
||||
data-testid={dataTestId}
|
||||
className={cn(
|
||||
'min-h-[34px] w-full rounded-lg border border-border bg-transparent px-[10px] py-[8px] text-[13px] text-foreground outline-none',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
editorClassName,
|
||||
)}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={onSelectionChange}
|
||||
onKeyUp={onSelectionChange}
|
||||
onMouseUp={onSelectionChange}
|
||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
segment.kind === 'text'
|
||||
? <span key={`text-${index}`}>{segment.text}</span>
|
||||
: (
|
||||
<InlineWikilinkChipView
|
||||
key={`chip-${segment.chip.entry.path}-${segment.chip.target}`}
|
||||
chip={segment.chip}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineWikilinkPaletteLayout({
|
||||
header,
|
||||
editor,
|
||||
suggestionList,
|
||||
emptyState,
|
||||
footer,
|
||||
}: {
|
||||
header?: React.ReactNode
|
||||
editor: React.ReactNode
|
||||
suggestionList: React.ReactNode
|
||||
emptyState?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
{header}
|
||||
{editor}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{suggestionList ?? emptyState}
|
||||
</div>
|
||||
{footer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
|
||||
import { getSortComparator } from '../utils/noteListHelpers'
|
||||
import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
|
||||
|
||||
@@ -107,7 +108,8 @@ describe('getSortComparator', () => {
|
||||
describe('NoteList sort controls', () => {
|
||||
beforeEach(() => {
|
||||
try {
|
||||
localStorage.removeItem('laputa-sort-preferences')
|
||||
localStorage.removeItem(APP_STORAGE_KEYS.sortPreferences)
|
||||
localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.sortPreferences)
|
||||
} catch {
|
||||
// ignore storage failures in tests
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ function DateValue({ value, onSave, autoOpen = false, onCancel }: {
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 border-none px-2 text-right text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded-md bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
|
||||
className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 border-none px-2 text-left text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded-md bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
|
||||
title={value}
|
||||
data-testid="date-display"
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function RenameDetectedBanner({ renames, onUpdate, onDismiss }: RenameDet
|
||||
<div className="flex items-center gap-3 border-b border-border bg-accent/50 px-4 py-2 text-[13px]">
|
||||
<ArrowsClockwise size={16} className="shrink-0 text-accent-foreground" />
|
||||
<span className="flex-1 text-foreground">
|
||||
{count} file{count !== 1 ? 's' : ''} renamed outside Laputa. Update wikilinks?
|
||||
{count} file{count !== 1 ? 's' : ''} renamed outside Tolaria. Update wikilinks?
|
||||
</span>
|
||||
<button
|
||||
className="shrink-0 cursor-pointer rounded-md bg-primary px-3 py-1 text-[12px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { SettingsPanel } from './SettingsPanel'
|
||||
import type { Settings } from '../types'
|
||||
|
||||
// Mock the tauri/mock-tauri calls used by GitHubSection
|
||||
const mockInvokeFn = vi.fn()
|
||||
const mockOpenExternalUrl = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args),
|
||||
}))
|
||||
|
||||
const emptySettings: Settings = {
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
@@ -48,7 +32,7 @@ describe('SettingsPanel', () => {
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sync')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show AI Provider Keys section', () => {
|
||||
@@ -68,8 +52,6 @@ describe('SettingsPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
auto_pull_interval_minutes: 5,
|
||||
}))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
@@ -132,8 +114,6 @@ describe('SettingsPanel', () => {
|
||||
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Enter', metaKey: true })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
auto_pull_interval_minutes: 5,
|
||||
}))
|
||||
})
|
||||
@@ -154,195 +134,12 @@ describe('SettingsPanel', () => {
|
||||
expect(screen.getByText(/to open settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
describe('GitHub OAuth section', () => {
|
||||
it('shows Login with GitHub button when not connected', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByTestId('github-login')).toBeInTheDocument()
|
||||
expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show GitHub token input field', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.queryByTestId('settings-key-github-token')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('ghp_... or gho_...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows connected state with username when GitHub is connected', () => {
|
||||
const connectedSettings: Settings = {
|
||||
...emptySettings,
|
||||
github_token: 'gho_test_token',
|
||||
github_username: 'lucaong',
|
||||
}
|
||||
render(
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByTestId('github-connected')).toBeInTheDocument()
|
||||
expect(screen.getByText('lucaong')).toBeInTheDocument()
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('github-disconnect')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears GitHub connection on disconnect', () => {
|
||||
const connectedSettings: Settings = {
|
||||
...emptySettings,
|
||||
github_token: 'gho_test_token',
|
||||
github_username: 'lucaong',
|
||||
}
|
||||
render(
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('github-disconnect'))
|
||||
|
||||
// onSave should be called with cleared GitHub fields
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('shows waiting state with user code during OAuth flow', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_device_flow_start') {
|
||||
return {
|
||||
device_code: 'test_device_code',
|
||||
user_code: 'TEST-1234',
|
||||
verification_uri: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}
|
||||
}
|
||||
if (cmd === 'github_device_flow_poll') {
|
||||
return { status: 'pending', access_token: null, error: 'authorization_pending' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('github-waiting')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('github-user-code')).toHaveTextContent('TEST-1234')
|
||||
})
|
||||
|
||||
expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://github.com/login/device')
|
||||
})
|
||||
|
||||
it('shows verification URL as clickable link in waiting state', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_device_flow_start') {
|
||||
return {
|
||||
device_code: 'test_device_code',
|
||||
user_code: 'TEST-1234',
|
||||
verification_uri: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}
|
||||
}
|
||||
if (cmd === 'github_device_flow_poll') {
|
||||
return { status: 'pending', access_token: null, error: 'authorization_pending' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
|
||||
await waitFor(() => {
|
||||
const urlButton = screen.getByTestId('github-open-url')
|
||||
expect(urlButton).toBeInTheDocument()
|
||||
expect(urlButton).toHaveTextContent('https://github.com/login/device')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows retry button when OAuth flow errors', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_device_flow_start') {
|
||||
throw 'Network error'
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('github-error')).toHaveTextContent('Network error')
|
||||
expect(screen.getByTestId('github-retry')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows GitHub section description about connecting', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays the actual backend error string when device flow start fails', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_device_flow_start') {
|
||||
// Tauri invoke rejects with a plain string, not an Error instance
|
||||
throw 'GitHub device flow not available. Ensure a GitHub App is registered.'
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('github-error')).toHaveTextContent(
|
||||
'GitHub device flow not available. Ensure a GitHub App is registered.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('prevents double-click by disabling button during login flow', async () => {
|
||||
let resolveStart: ((v: unknown) => void) | null = null
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_device_flow_start') {
|
||||
return new Promise(r => { resolveStart = r })
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
const loginBtn = screen.getByTestId('github-login') as HTMLButtonElement
|
||||
fireEvent.click(loginBtn)
|
||||
|
||||
// Button should be disabled while waiting
|
||||
await waitFor(() => {
|
||||
expect(loginBtn.disabled).toBe(true)
|
||||
})
|
||||
|
||||
// Clean up
|
||||
resolveStart?.({
|
||||
device_code: 'dc', user_code: 'UC-1234',
|
||||
verification_uri: 'https://github.com/login/device',
|
||||
expires_in: 900, interval: 5,
|
||||
})
|
||||
})
|
||||
it('does not show the removed GitHub auth section', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.queryByText('GitHub')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Connect your GitHub account/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Privacy & Telemetry section', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { X, GithubLogo, SignOut } from '@phosphor-icons/react'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import { X } from '@phosphor-icons/react'
|
||||
import type { Settings } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { Switch } from './ui/switch'
|
||||
@@ -14,52 +13,6 @@ interface SettingsPanelProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
|
||||
// --- GitHub OAuth Section ---
|
||||
|
||||
interface GitHubSectionProps {
|
||||
githubUsername: string | null
|
||||
githubToken: string | null
|
||||
onConnected: (token: string, username: string) => void
|
||||
onDisconnect: () => void
|
||||
}
|
||||
|
||||
function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect }: GitHubSectionProps) {
|
||||
const isConnected = !!githubToken && !!githubUsername
|
||||
|
||||
if (isConnected) {
|
||||
return <GitHubConnectedRow username={githubUsername!} onDisconnect={onDisconnect} />
|
||||
}
|
||||
|
||||
return <GitHubDeviceFlow onConnected={onConnected} />
|
||||
}
|
||||
|
||||
function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDisconnect: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div
|
||||
className="flex items-center gap-2 border border-border rounded px-3 py-2 flex-1"
|
||||
style={{ minHeight: 36 }}
|
||||
data-testid="github-connected"
|
||||
>
|
||||
<GithubLogo size={16} weight="fill" style={{ color: 'var(--foreground)' }} />
|
||||
<span style={{ fontSize: 13, color: 'var(--foreground)', fontWeight: 500 }}>{username}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Connected</span>
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground hover:border-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
onClick={onDisconnect}
|
||||
title="Disconnect GitHub account"
|
||||
data-testid="github-disconnect"
|
||||
>
|
||||
<SignOut size={14} />
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Settings Panel ---
|
||||
|
||||
function isSaveShortcut(event: React.KeyboardEvent): boolean {
|
||||
@@ -97,8 +50,6 @@ function SettingsPanelInner({
|
||||
onSaveExplicitOrganization,
|
||||
onClose,
|
||||
}: SettingsPanelInnerProps) {
|
||||
const [githubToken, setGithubToken] = useState(settings.github_token)
|
||||
const [githubUsername, setGithubUsername] = useState(settings.github_username)
|
||||
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
|
||||
const [releaseChannel, setReleaseChannel] = useState(settings.release_channel ?? 'stable')
|
||||
const [crashReporting, setCrashReporting] = useState(settings.crash_reporting_enabled ?? false)
|
||||
@@ -115,16 +66,14 @@ function SettingsPanelInner({
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const buildSettings = useCallback((ghOverride?: { token: string | null; username: string | null }): Settings => ({
|
||||
github_token: ghOverride ? ghOverride.token : (githubToken ?? null),
|
||||
github_username: ghOverride ? ghOverride.username : (githubUsername ?? null),
|
||||
const buildSettings = useCallback((): Settings => ({
|
||||
auto_pull_interval_minutes: pullInterval,
|
||||
telemetry_consent: (crashReporting || analytics) ? true : (settings.telemetry_consent === null ? null : false),
|
||||
crash_reporting_enabled: crashReporting,
|
||||
analytics_enabled: analytics,
|
||||
anonymous_id: (crashReporting || analytics) ? (settings.anonymous_id ?? crypto.randomUUID()) : settings.anonymous_id,
|
||||
release_channel: releaseChannel === 'stable' ? null : releaseChannel,
|
||||
}), [githubToken, githubUsername, pullInterval, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
}), [pullInterval, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
|
||||
const handleSave = () => {
|
||||
const prevAnalytics = settings.analytics_enabled ?? false
|
||||
@@ -136,18 +85,6 @@ function SettingsPanelInner({
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleGitHubConnected = useCallback((token: string, username: string) => {
|
||||
setGithubToken(token)
|
||||
setGithubUsername(username)
|
||||
onSave(buildSettings({ token, username }))
|
||||
}, [onSave, buildSettings])
|
||||
|
||||
const handleGitHubDisconnect = useCallback(() => {
|
||||
setGithubToken(null)
|
||||
setGithubUsername(null)
|
||||
onSave(buildSettings({ token: null, username: null }))
|
||||
}, [onSave, buildSettings])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
@@ -174,8 +111,6 @@ function SettingsPanelInner({
|
||||
>
|
||||
<SettingsHeader onClose={onClose} />
|
||||
<SettingsBody
|
||||
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
|
||||
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
|
||||
pullInterval={pullInterval} setPullInterval={setPullInterval}
|
||||
releaseChannel={releaseChannel} setReleaseChannel={setReleaseChannel}
|
||||
explicitOrganization={explicitOrganization}
|
||||
@@ -208,9 +143,6 @@ function SettingsHeader({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
|
||||
interface SettingsBodyProps {
|
||||
githubToken: string | null; githubUsername: string | null
|
||||
onGitHubConnected: (token: string, username: string) => void
|
||||
onGitHubDisconnect: () => void
|
||||
pullInterval: number; setPullInterval: (v: number) => void
|
||||
releaseChannel: string; setReleaseChannel: (v: string) => void
|
||||
explicitOrganization: boolean; setExplicitOrganization: (v: boolean) => void
|
||||
@@ -221,22 +153,6 @@ interface SettingsBodyProps {
|
||||
function SettingsBody(props: SettingsBodyProps) {
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20, overflow: 'auto' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>GitHub</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Connect your GitHub account to clone and sync vaults.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GitHubSection
|
||||
githubUsername={props.githubUsername}
|
||||
githubToken={props.githubToken}
|
||||
onConnected={props.onGitHubConnected}
|
||||
onDisconnect={props.onGitHubDisconnect}
|
||||
/>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Sync</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
@@ -298,7 +214,7 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Privacy & Telemetry</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Anonymous data helps us fix bugs and improve Laputa. No vault content, note titles, or file paths are ever sent.
|
||||
Anonymous data helps us fix bugs and improve Tolaria. No vault content, note titles, or file paths are ever sent.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -320,7 +236,7 @@ function OrganizationWorkflowSection({
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Workflow</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Choose whether Laputa shows the Inbox workflow and the organized toggle.
|
||||
Choose whether Tolaria shows the Inbox workflow and the organized toggle.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -187,12 +187,12 @@ describe('StatusBar', () => {
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
onOpenLocalFolder={vi.fn()}
|
||||
onConnectGitHub={vi.fn()}
|
||||
onCloneVault={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
expect(screen.getByText('Open local folder')).toBeInTheDocument()
|
||||
expect(screen.getByText('Connect GitHub repo')).toBeInTheDocument()
|
||||
expect(screen.getByText('Clone Git repo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Changes badge with count when modifiedCount is > 0', () => {
|
||||
|
||||
@@ -18,12 +18,11 @@ interface StatusBarProps {
|
||||
onSwitchVault: (path: string) => void
|
||||
onOpenSettings?: () => void
|
||||
onOpenLocalFolder?: () => void
|
||||
onConnectGitHub?: () => void
|
||||
onCloneVault?: () => void
|
||||
onClickPending?: () => void
|
||||
onClickPulse?: () => void
|
||||
onCommitPush?: () => void
|
||||
isGitVault?: boolean
|
||||
hasGitHub?: boolean
|
||||
syncStatus?: SyncStatus
|
||||
lastSyncTime?: number | null
|
||||
conflictCount?: number
|
||||
@@ -52,12 +51,11 @@ export function StatusBar({
|
||||
onSwitchVault,
|
||||
onOpenSettings,
|
||||
onOpenLocalFolder,
|
||||
onConnectGitHub,
|
||||
onCloneVault,
|
||||
onClickPending,
|
||||
onClickPulse,
|
||||
onCommitPush,
|
||||
isGitVault = false,
|
||||
hasGitHub,
|
||||
syncStatus = 'idle',
|
||||
lastSyncTime = null,
|
||||
conflictCount = 0,
|
||||
@@ -107,8 +105,7 @@ export function StatusBar({
|
||||
vaults={vaults}
|
||||
onSwitchVault={onSwitchVault}
|
||||
onOpenLocalFolder={onOpenLocalFolder}
|
||||
onConnectGitHub={onConnectGitHub}
|
||||
hasGitHub={hasGitHub}
|
||||
onCloneVault={onCloneVault}
|
||||
onClickPending={onClickPending}
|
||||
onClickPulse={onClickPulse}
|
||||
onCommitPush={onCommitPush}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TelemetryConsentDialog } from './TelemetryConsentDialog'
|
||||
describe('TelemetryConsentDialog', () => {
|
||||
it('renders the consent dialog', () => {
|
||||
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
|
||||
expect(screen.getByText('Help improve Laputa')).toBeDefined()
|
||||
expect(screen.getByText('Help improve Tolaria')).toBeDefined()
|
||||
expect(screen.getByText(/anonymous crash reports/i)).toBeDefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 600, color: 'var(--foreground)', margin: 0 }}>
|
||||
Help improve Laputa
|
||||
Help improve Tolaria
|
||||
</h2>
|
||||
<p style={{ fontSize: 13, color: 'var(--muted-foreground)', lineHeight: 1.6, marginTop: 8 }}>
|
||||
Send anonymous crash reports to help us fix bugs faster.
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('UpdateBanner', () => {
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
|
||||
expect(screen.getByTestId('update-banner')).toBeTruthy()
|
||||
expect(screen.getByText(/Laputa 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText(/Tolaria 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText('is available')).toBeTruthy()
|
||||
expect(screen.getByTestId('update-now-btn')).toBeTruthy()
|
||||
expect(screen.getByTestId('update-release-notes')).toBeTruthy()
|
||||
@@ -78,7 +78,7 @@ describe('UpdateBanner', () => {
|
||||
const status: UpdateStatus = { state: 'downloading', version: '1.5.0', progress: 0.65 }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
|
||||
expect(screen.getByText(/Downloading Laputa 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText(/Downloading Tolaria 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText('65%')).toBeTruthy()
|
||||
|
||||
const progressBar = screen.getByTestId('update-progress')
|
||||
@@ -98,7 +98,7 @@ describe('UpdateBanner', () => {
|
||||
const status: UpdateStatus = { state: 'ready', version: '1.5.0' }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
|
||||
expect(screen.getByText(/Laputa 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText(/Tolaria 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText(/restart to apply/)).toBeTruthy()
|
||||
expect(screen.getByTestId('update-restart-btn')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ export function UpdateBanner({ status, actions }: UpdateBannerProps) {
|
||||
<>
|
||||
<Download size={14} style={{ color: '#fff', flexShrink: 0 }} />
|
||||
<span>
|
||||
<strong>Laputa {status.version}</strong> is available
|
||||
<strong>Tolaria {status.version}</strong> is available
|
||||
</span>
|
||||
<button
|
||||
data-testid="update-release-notes"
|
||||
@@ -87,7 +87,7 @@ export function UpdateBanner({ status, actions }: UpdateBannerProps) {
|
||||
{status.state === 'downloading' && (
|
||||
<>
|
||||
<RefreshCw size={14} style={{ color: '#fff', flexShrink: 0, animation: 'spin 1s linear infinite' }} />
|
||||
<span>Downloading Laputa {status.version}...</span>
|
||||
<span>Downloading Tolaria {status.version}...</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -119,7 +119,7 @@ export function UpdateBanner({ status, actions }: UpdateBannerProps) {
|
||||
<>
|
||||
<RefreshCw size={14} style={{ color: 'var(--accent-green, #0F7B0F)', flexShrink: 0 }} />
|
||||
<span>
|
||||
<strong>Laputa {status.version}</strong> is ready — restart to apply
|
||||
<strong>Tolaria {status.version}</strong> is ready — restart to apply
|
||||
</span>
|
||||
<button
|
||||
data-testid="update-restart-btn"
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('WelcomeScreen', () => {
|
||||
describe('welcome mode', () => {
|
||||
it('renders welcome title and subtitle', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByText('Welcome to Laputa')).toBeInTheDocument()
|
||||
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ export function WelcomeScreen({
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h1 style={TITLE_STYLE}>
|
||||
{isWelcome ? 'Welcome to Laputa' : 'Vault not found'}
|
||||
{isWelcome ? 'Welcome to Tolaria' : 'Vault not found'}
|
||||
</h1>
|
||||
<p style={{ ...SUBTITLE_STYLE, marginTop: 8 }}>
|
||||
{isWelcome
|
||||
@@ -260,9 +260,9 @@ export function WelcomeScreen({
|
||||
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
|
||||
iconBg="var(--accent-purple-light, #F3E8FF)"
|
||||
label="Get started with a template"
|
||||
description={`Download the starter vault from GitHub \u2014 suggested path: ${defaultVaultPath}`}
|
||||
description={`Download the starter vault template \u2014 suggested path: ${defaultVaultPath}`}
|
||||
loadingLabel="Downloading template…"
|
||||
loadingDescription="Cloning the Getting Started vault from GitHub"
|
||||
loadingDescription="Cloning the Getting Started vault template"
|
||||
onClick={onCreateVault}
|
||||
disabled={busy}
|
||||
loading={creatingAction === 'template'}
|
||||
@@ -272,7 +272,7 @@ export function WelcomeScreen({
|
||||
|
||||
{creatingAction === 'template' && (
|
||||
<p style={STATUS_STYLE} data-testid="welcome-status" role="status" aria-live="polite">
|
||||
Downloading the Getting Started vault from GitHub…
|
||||
Downloading the Getting Started vault template…
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -31,249 +31,154 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/alpha.md', title: 'Alpha', filename: 'alpha.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person' }),
|
||||
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person', aliases: ['BLT'] }),
|
||||
makeEntry({ path: '/vault/gamma.md', title: 'Gamma', filename: 'gamma.md' }),
|
||||
makeEntry({ path: '/vault/archived.md', title: 'Archived', filename: 'archived.md', archived: true }),
|
||||
]
|
||||
|
||||
/** Wrapper that manages controlled value state so onChange/selectSuggestion work correctly. */
|
||||
function Controlled({ onSend, ...props }: {
|
||||
entries: VaultEntry[]
|
||||
function Controlled({
|
||||
onSend,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
}: {
|
||||
onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<WikilinkChatInput
|
||||
entries={props.entries}
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSend={onSend ?? vi.fn()}
|
||||
disabled={props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Helper: type in the input and wait for debounce to flush. */
|
||||
async function typeAndWait(text: string) {
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: text, selectionStart: text.length },
|
||||
})
|
||||
await act(() => { vi.advanceTimersByTime(150) })
|
||||
function setSelection(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const targetNode = editor.firstChild ?? editor
|
||||
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
|
||||
? Math.min(offset, targetNode.textContent?.length ?? 0)
|
||||
: Math.min(offset, targetNode.childNodes.length)
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(targetNode, safeOffset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function updateEditorText(text: string) {
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
fireEvent.focus(editor)
|
||||
editor.textContent = text
|
||||
setSelection(editor, text.length)
|
||||
fireEvent.input(editor)
|
||||
}
|
||||
|
||||
function clickFirstSuggestion() {
|
||||
const rows = screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
fireEvent.click(rows[0])
|
||||
}
|
||||
|
||||
describe('WikilinkChatInput', () => {
|
||||
it('renders input with placeholder', () => {
|
||||
render(<Controlled entries={entries} placeholder="Ask something..." />)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask something...')
|
||||
it('renders the placeholder overlay for an empty draft', () => {
|
||||
render(<Controlled placeholder="Ask something..." />)
|
||||
expect(screen.getByText('Ask something...')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask something...')
|
||||
})
|
||||
|
||||
it('calls onChange when typing', () => {
|
||||
it('calls onChange when the draft changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="" onChange={onChange} onSend={vi.fn()} />,
|
||||
)
|
||||
fireEvent.change(screen.getByTestId('agent-input'), { target: { value: 'hello' } })
|
||||
|
||||
updateEditorText('hello')
|
||||
expect(onChange).toHaveBeenCalledWith('hello')
|
||||
})
|
||||
|
||||
it('shows wikilink menu when [[ is typed', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('shows wikilink suggestions after typing [[', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('[[a')
|
||||
|
||||
it('filters suggestions matching query', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Alpha')
|
||||
expect(menu.textContent).not.toContain('Beta')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('excludes archived entries from suggestions', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[arch')
|
||||
const menu = screen.queryByTestId('wikilink-menu')
|
||||
if (menu) {
|
||||
expect(menu.textContent).not.toContain('Archived')
|
||||
}
|
||||
vi.useRealTimers()
|
||||
it('matches suggestions by alias', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('[[BLT')
|
||||
|
||||
expect(screen.getByTestId('wikilink-menu').textContent).toContain('Beta')
|
||||
})
|
||||
|
||||
it('selects suggestion on click and creates pill', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
it('renders selected wikilinks inline instead of in a separate pill strip', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('edit my [[alp')
|
||||
clickFirstSuggestion()
|
||||
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
const items = menu.querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
fireEvent.click(items[0])
|
||||
|
||||
const pills = screen.queryAllByTestId('reference-pill')
|
||||
expect(pills.length).toBe(1)
|
||||
vi.useRealTimers()
|
||||
expect(screen.queryByTestId('reference-pill')).toBeNull()
|
||||
expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-input').textContent).toContain('Alpha')
|
||||
})
|
||||
|
||||
it('does not duplicate pills for same note', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
|
||||
// First selection
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
// Second selection of same note
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1) // No duplicate
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('removes pill when x button is clicked', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
const pill = screen.getByTestId('reference-pill')
|
||||
fireEvent.click(pill.querySelector('button')!)
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('navigates suggestions with keyboard', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[a')
|
||||
|
||||
const input = screen.getByTestId('agent-input')
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'ArrowUp' })
|
||||
// Escape closes menu
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('selects suggestion with Enter key', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
it('selects a suggestion with Enter before sending the draft', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled onSend={onSend} />)
|
||||
updateEditorText('edit my [[alp')
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument()
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSend with text and references on Enter without menu', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled entries={entries} onSend={onSend} />)
|
||||
it('deletes an inline chip with a single Backspace', () => {
|
||||
render(<Controlled />)
|
||||
updateEditorText('edit my [[alp')
|
||||
clickFirstSuggestion()
|
||||
|
||||
// Type non-wikilink text
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: 'hello', selectionStart: 5 },
|
||||
})
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
fireEvent.keyDown(editor, { key: 'Backspace' })
|
||||
|
||||
expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull()
|
||||
})
|
||||
|
||||
it('submits serialized wikilink text and resolved references', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled onSend={onSend} />)
|
||||
|
||||
updateEditorText('edit my [[alpha]] essay')
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(onSend).toHaveBeenCalledWith('hello', [])
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('edit my [[alpha]] essay', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not send on Enter+Shift', () => {
|
||||
it('does not send on Shift+Enter', () => {
|
||||
const onSend = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="hello" onChange={vi.fn()} onSend={onSend} />,
|
||||
)
|
||||
render(<Controlled onSend={onSend} />)
|
||||
|
||||
updateEditorText('hello')
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not send on Enter when input is empty', () => {
|
||||
const onSend = vi.fn()
|
||||
render(
|
||||
<WikilinkChatInput entries={entries} value="" onChange={vi.fn()} onSend={onSend} />,
|
||||
)
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
it('marks the editor disabled when disabled is true', () => {
|
||||
render(<Controlled disabled />)
|
||||
|
||||
it('disables input when disabled prop is true', () => {
|
||||
render(<Controlled entries={entries} disabled />)
|
||||
expect((screen.getByTestId('agent-input') as HTMLInputElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes menu when ]] is typed', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
|
||||
|
||||
// Type ]] to close
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: '[[alp]]', selectionStart: 7 },
|
||||
})
|
||||
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows type badge for non-Note types in suggestions', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[alp')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Project')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('matches by alias', async () => {
|
||||
vi.useFakeTimers()
|
||||
const entriesWithAlias = [
|
||||
...entries,
|
||||
makeEntry({ path: '/vault/delta.md', title: 'Delta', filename: 'delta.md', aliases: ['DLT'] }),
|
||||
]
|
||||
render(<Controlled entries={entriesWithAlias} />)
|
||||
await typeAndWait('[[DLT')
|
||||
const menu = screen.getByTestId('wikilink-menu')
|
||||
expect(menu.textContent).toContain('Delta')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sends references with onSend after selecting pills', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled entries={entries} onSend={onSend} />)
|
||||
|
||||
// Select a pill first
|
||||
await typeAndWait('[[alp')
|
||||
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
|
||||
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
|
||||
|
||||
// Type a message and send
|
||||
fireEvent.change(screen.getByTestId('agent-input'), {
|
||||
target: { value: 'tell me about it', selectionStart: 16 },
|
||||
})
|
||||
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
|
||||
|
||||
expect(onSend).toHaveBeenCalledOnce()
|
||||
const [text, refs] = onSend.mock.calls[0]
|
||||
expect(text).toBe('tell me about it')
|
||||
expect(refs).toHaveLength(1)
|
||||
expect(refs[0].title).toBe('Alpha')
|
||||
expect(refs[0].path).toBe('/vault/alpha.md')
|
||||
vi.useRealTimers()
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
expect(editor).toHaveAttribute('contenteditable', 'false')
|
||||
expect(editor).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
/**
|
||||
* Chat input with [[wikilink]] autocomplete.
|
||||
*
|
||||
* When the user types `[[`, a dropdown appears with vault note suggestions.
|
||||
* Selecting a note inserts a colored pill in the input and records a reference.
|
||||
*/
|
||||
import { useState, useRef, useMemo, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { bestSearchRank } from '../utils/fuzzyMatch'
|
||||
|
||||
const MAX_SUGGESTIONS = 20
|
||||
const MIN_QUERY_LENGTH = 1
|
||||
const DEBOUNCE_MS = 100
|
||||
import { InlineWikilinkInput } from './InlineWikilinkInput'
|
||||
|
||||
interface WikilinkChatInputProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -21,248 +9,27 @@ interface WikilinkChatInputProps {
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
interface Pill {
|
||||
title: string
|
||||
path: string
|
||||
type: string | null
|
||||
color?: string
|
||||
lightColor?: string
|
||||
}
|
||||
|
||||
interface SuggestionEntry {
|
||||
title: string
|
||||
path: string
|
||||
isA: string | null
|
||||
color?: string
|
||||
lightColor?: string
|
||||
}
|
||||
|
||||
function matchEntries(
|
||||
entries: VaultEntry[],
|
||||
query: string,
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): SuggestionEntry[] {
|
||||
if (query.length < MIN_QUERY_LENGTH) return []
|
||||
const lower = query.toLowerCase()
|
||||
const matches = entries
|
||||
.filter(e =>
|
||||
!e.archived && (
|
||||
e.title.toLowerCase().includes(lower) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lower))
|
||||
),
|
||||
)
|
||||
.map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) }))
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return {
|
||||
title: e.title,
|
||||
path: e.path,
|
||||
isA: e.isA,
|
||||
color: e.isA ? getTypeColor(e.isA, te?.color) : undefined,
|
||||
lightColor: e.isA ? getTypeLightColor(e.isA, te?.color) : undefined,
|
||||
}
|
||||
})
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function WikilinkChatInput({
|
||||
entries, value, onChange, onSend, disabled, placeholder, inputRef: externalRef,
|
||||
entries,
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
inputRef,
|
||||
}: WikilinkChatInputProps) {
|
||||
const [pills, setPills] = useState<Pill[]>([])
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const internalRef = useRef<HTMLInputElement>(null)
|
||||
const inputRefToUse = externalRef ?? internalRef
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => showMenu ? matchEntries(entries, debouncedQuery, typeEntryMap) : [],
|
||||
[entries, debouncedQuery, typeEntryMap, showMenu],
|
||||
)
|
||||
|
||||
// Clamp selection to valid range
|
||||
const clampedIndex = suggestions.length > 0
|
||||
? Math.min(selectedIndex, suggestions.length - 1)
|
||||
: 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuRef.current || clampedIndex < 0) return
|
||||
const el = menuRef.current.children[clampedIndex] as HTMLElement | undefined
|
||||
el?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [clampedIndex])
|
||||
|
||||
function selectSuggestion(suggestion: SuggestionEntry) {
|
||||
const cursor = inputRefToUse.current?.selectionStart ?? value.length
|
||||
const textBefore = value.slice(0, cursor)
|
||||
const bracketIdx = textBefore.lastIndexOf('[[')
|
||||
if (bracketIdx < 0) return
|
||||
|
||||
const textAfter = value.slice(cursor)
|
||||
const newValue = textBefore.slice(0, bracketIdx) + textAfter
|
||||
|
||||
onChange(newValue)
|
||||
setPills(prev => {
|
||||
if (prev.some(p => p.path === suggestion.path)) return prev
|
||||
return [...prev, {
|
||||
title: suggestion.title,
|
||||
path: suggestion.path,
|
||||
type: suggestion.isA,
|
||||
color: suggestion.color,
|
||||
lightColor: suggestion.lightColor,
|
||||
}]
|
||||
})
|
||||
setShowMenu(false)
|
||||
setTimeout(() => inputRefToUse.current?.focus(), 0)
|
||||
}
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const newValue = e.target.value
|
||||
onChange(newValue)
|
||||
|
||||
const cursor = e.target.selectionStart ?? newValue.length
|
||||
const textBefore = newValue.slice(0, cursor)
|
||||
const bracketIdx = textBefore.lastIndexOf('[[')
|
||||
|
||||
if (bracketIdx >= 0 && !textBefore.slice(bracketIdx).includes(']]')) {
|
||||
const query = textBefore.slice(bracketIdx + 2)
|
||||
setShowMenu(true)
|
||||
setSelectedIndex(0)
|
||||
clearTimeout(debounceTimer.current)
|
||||
debounceTimer.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS)
|
||||
} else {
|
||||
setShowMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (showMenu && suggestions.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i + 1) % suggestions.length)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1))
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
selectSuggestion(suggestions[clampedIndex])
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setShowMenu(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!value.trim() && pills.length === 0) return
|
||||
const references: NoteReference[] = pills.map(p => ({
|
||||
title: p.title,
|
||||
path: p.path,
|
||||
type: p.type,
|
||||
}))
|
||||
onSend(value, references)
|
||||
setPills([])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{pills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
|
||||
{pills.map(pill => (
|
||||
<span
|
||||
key={pill.path}
|
||||
className="inline-flex items-center gap-1 text-xs"
|
||||
style={{
|
||||
background: pill.lightColor ?? 'var(--muted)',
|
||||
color: pill.color ?? 'var(--foreground)',
|
||||
borderRadius: 9999,
|
||||
padding: '1px 8px 1px 6px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
data-testid="reference-pill"
|
||||
>
|
||||
{pill.title}
|
||||
<button
|
||||
className="border-none bg-transparent p-0 cursor-pointer"
|
||||
style={{ color: 'inherit', opacity: 0.6, fontSize: 10, lineHeight: 1 }}
|
||||
onClick={() => setPills(prev => prev.filter(p => p.path !== pill.path))}
|
||||
tabIndex={-1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={inputRefToUse}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 border border-border bg-transparent text-foreground"
|
||||
style={{
|
||||
fontSize: 13, borderRadius: 8, padding: '8px 10px',
|
||||
outline: 'none', fontFamily: 'inherit', width: '100%', boxSizing: 'border-box',
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
data-testid="agent-input"
|
||||
/>
|
||||
{showMenu && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="wikilink-menu"
|
||||
style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, right: 0,
|
||||
marginBottom: 4, maxHeight: 260, overflowY: 'auto',
|
||||
}}
|
||||
data-testid="wikilink-menu"
|
||||
>
|
||||
{suggestions.map((s, i) => (
|
||||
<div
|
||||
key={s.path}
|
||||
className="flex items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 13,
|
||||
background: i === clampedIndex ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => selectSuggestion(s)}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
>
|
||||
<span className="truncate">{s.title}</span>
|
||||
{s.isA && s.isA !== 'Note' && (
|
||||
<span
|
||||
className="shrink-0 text-xs"
|
||||
style={{
|
||||
color: s.color,
|
||||
backgroundColor: s.lightColor,
|
||||
borderRadius: 9999,
|
||||
padding: '1px 8px',
|
||||
}}
|
||||
>
|
||||
{s.isA}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InlineWikilinkInput
|
||||
entries={entries}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSend}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
116
src/components/editorRawModeSync.ts
Normal file
116
src/components/editorRawModeSync.ts
Normal 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
|
||||
}
|
||||
134
src/components/inlineWikilinkDom.ts
Normal file
134
src/components/inlineWikilinkDom.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
chipToken,
|
||||
normalizeInlineWikilinkValue,
|
||||
} from './inlineWikilinkTokens'
|
||||
|
||||
export function serializeInlineNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return normalizeInlineWikilinkValue(node.textContent ?? '')
|
||||
}
|
||||
|
||||
if (node instanceof HTMLElement) {
|
||||
if (node.dataset.chipTarget) {
|
||||
return chipToken(node.dataset.chipTarget)
|
||||
}
|
||||
|
||||
if (node.tagName === 'BR') return ''
|
||||
}
|
||||
|
||||
return Array.from(node.childNodes).map(serializeInlineNode).join('')
|
||||
}
|
||||
|
||||
function selectionFallsOutsideEditor(
|
||||
selection: Selection | null,
|
||||
root: HTMLDivElement,
|
||||
): boolean {
|
||||
return (
|
||||
!selection ||
|
||||
selection.rangeCount === 0 ||
|
||||
!selection.anchorNode ||
|
||||
!root.contains(selection.anchorNode)
|
||||
)
|
||||
}
|
||||
|
||||
export function readSelectionIndex(root: HTMLDivElement): number {
|
||||
const currentSelection = window.getSelection()
|
||||
if (
|
||||
!currentSelection ||
|
||||
selectionFallsOutsideEditor(currentSelection, root)
|
||||
) {
|
||||
return serializeInlineNode(root).length
|
||||
}
|
||||
|
||||
const range = currentSelection.getRangeAt(0).cloneRange()
|
||||
range.setStart(root, 0)
|
||||
return serializeInlineNode(range.cloneContents()).length
|
||||
}
|
||||
|
||||
export function applySelectionIndex(
|
||||
root: HTMLDivElement,
|
||||
selectionIndex: number,
|
||||
) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const range = document.createRange()
|
||||
const clampedIndex = Math.max(0, selectionIndex)
|
||||
const found = placeRangeAtIndex(root, clampedIndex, range)
|
||||
|
||||
if (!found) {
|
||||
range.selectNodeContents(root)
|
||||
range.collapse(false)
|
||||
}
|
||||
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function placeRangeAtIndex(
|
||||
node: Node,
|
||||
selectionIndex: number,
|
||||
range: Range,
|
||||
): boolean {
|
||||
let remaining = selectionIndex
|
||||
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const textPlacement = placeRangeInTextNode(child, remaining, range)
|
||||
if (textPlacement.placed) return true
|
||||
remaining = textPlacement.remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (!(child instanceof HTMLElement)) continue
|
||||
|
||||
if (child.dataset.chipTarget) {
|
||||
const chipPlacement = placeRangeAroundChip(child, remaining, range)
|
||||
if (chipPlacement.placed) return true
|
||||
remaining = chipPlacement.remaining
|
||||
continue
|
||||
}
|
||||
|
||||
if (placeRangeAtIndex(child, remaining, range)) return true
|
||||
remaining -= serializeInlineNode(child).length
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function placeRangeInTextNode(
|
||||
node: Node,
|
||||
remaining: number,
|
||||
range: Range,
|
||||
) {
|
||||
const textLength = normalizeInlineWikilinkValue(node.textContent ?? '').length
|
||||
if (remaining <= textLength) {
|
||||
range.setStart(node, remaining)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
return { placed: false, remaining: remaining - textLength }
|
||||
}
|
||||
|
||||
function placeRangeAroundChip(
|
||||
node: HTMLElement,
|
||||
remaining: number,
|
||||
range: Range,
|
||||
) {
|
||||
const tokenLength = chipToken(node.dataset.chipTarget ?? '').length
|
||||
|
||||
if (remaining <= 0) {
|
||||
range.setStartBefore(node)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
if (remaining <= tokenLength) {
|
||||
range.setStartAfter(node)
|
||||
range.collapse(true)
|
||||
return { placed: true, remaining: 0 }
|
||||
}
|
||||
|
||||
return { placed: false, remaining: remaining - tokenLength }
|
||||
}
|
||||
117
src/components/inlineWikilinkKeydown.ts
Normal file
117
src/components/inlineWikilinkKeydown.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type React from 'react'
|
||||
|
||||
interface HandleSuggestionKeysArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
suggestionsOpen: boolean
|
||||
onCycleSuggestions: (direction: 1 | -1) => void
|
||||
onSelectSuggestion: () => void
|
||||
}
|
||||
|
||||
function handleSuggestionKeys({
|
||||
event,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
}: HandleSuggestionKeysArgs): boolean {
|
||||
if (!suggestionsOpen) return false
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
onCycleSuggestions(1)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
onCycleSuggestions(-1)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onSelectSuggestion()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
interface HandleDeleteKeysArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
|
||||
}
|
||||
|
||||
function handleDeleteKeys({
|
||||
event,
|
||||
onDeleteAdjacentChip,
|
||||
}: HandleDeleteKeysArgs): boolean {
|
||||
if (event.key === 'Backspace' && onDeleteAdjacentChip('backward')) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Delete' && onDeleteAdjacentChip('forward')) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
interface HandleSubmitKeyArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
canSubmit: boolean
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
function handleSubmitKey({
|
||||
event,
|
||||
canSubmit,
|
||||
onSubmit,
|
||||
}: HandleSubmitKeyArgs): boolean {
|
||||
if (!canSubmit) return false
|
||||
if (event.key !== 'Enter' || event.shiftKey) return false
|
||||
|
||||
event.preventDefault()
|
||||
onSubmit()
|
||||
return true
|
||||
}
|
||||
|
||||
interface HandleInlineWikilinkKeyDownArgs {
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
disabled: boolean
|
||||
suggestionsOpen: boolean
|
||||
onCycleSuggestions: (direction: 1 | -1) => void
|
||||
onSelectSuggestion: () => void
|
||||
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
|
||||
canSubmit: boolean
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export function handleInlineWikilinkKeyDown({
|
||||
event,
|
||||
disabled,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
onDeleteAdjacentChip,
|
||||
canSubmit,
|
||||
onSubmit,
|
||||
}: HandleInlineWikilinkKeyDownArgs) {
|
||||
if (disabled) return
|
||||
|
||||
if (handleSuggestionKeys({
|
||||
event,
|
||||
suggestionsOpen,
|
||||
onCycleSuggestions,
|
||||
onSelectSuggestion,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleDeleteKeys({ event, onDeleteAdjacentChip })) {
|
||||
return
|
||||
}
|
||||
|
||||
handleSubmitKey({ event, canSubmit, onSubmit })
|
||||
}
|
||||
78
src/components/inlineWikilinkSuggestions.ts
Normal file
78
src/components/inlineWikilinkSuggestions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
MAX_RESULTS,
|
||||
deduplicateByPath,
|
||||
disambiguateTitles,
|
||||
preFilterWikilinks,
|
||||
type WikilinkBaseItem,
|
||||
} from '../utils/wikilinkSuggestions'
|
||||
import { toInlineWikilinkTarget } from './inlineWikilinkTokens'
|
||||
|
||||
export interface InlineWikilinkSuggestion {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface SuggestionItem extends WikilinkBaseItem {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
}
|
||||
|
||||
function toSuggestionItems(entries: VaultEntry[]): SuggestionItem[] {
|
||||
return entries
|
||||
.filter((entry) => !entry.archived)
|
||||
.map((entry) => ({
|
||||
entry,
|
||||
target: toInlineWikilinkTarget(entry),
|
||||
title: entry.title,
|
||||
aliases: entry.aliases,
|
||||
group: entry.isA ?? 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))
|
||||
}
|
||||
|
||||
function toSuggestion(item: SuggestionItem): InlineWikilinkSuggestion {
|
||||
return {
|
||||
entry: item.entry,
|
||||
target: item.target,
|
||||
title: item.title,
|
||||
}
|
||||
}
|
||||
|
||||
function matchSingleCharacterQuery(
|
||||
items: SuggestionItem[],
|
||||
query: string,
|
||||
): SuggestionItem[] {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return items.filter((item) =>
|
||||
item.title.toLowerCase().includes(lowerQuery) ||
|
||||
item.aliases.some((alias) => alias.toLowerCase().includes(lowerQuery)) ||
|
||||
item.group.toLowerCase().includes(lowerQuery) ||
|
||||
item.path.toLowerCase().includes(lowerQuery),
|
||||
)
|
||||
}
|
||||
|
||||
function buildTopSuggestions(items: SuggestionItem[]): InlineWikilinkSuggestion[] {
|
||||
return [...items]
|
||||
.sort((left, right) => left.title.localeCompare(right.title))
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map(toSuggestion)
|
||||
}
|
||||
|
||||
export function buildInlineWikilinkSuggestions(
|
||||
entries: VaultEntry[],
|
||||
query: string,
|
||||
): InlineWikilinkSuggestion[] {
|
||||
const items = toSuggestionItems(entries)
|
||||
if (query.length === 0) return buildTopSuggestions(items)
|
||||
|
||||
const matchedItems = query.length === 1
|
||||
? matchSingleCharacterQuery(items, query)
|
||||
: preFilterWikilinks(items, query)
|
||||
|
||||
return disambiguateTitles(deduplicateByPath(matchedItems))
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map(toSuggestion)
|
||||
}
|
||||
149
src/components/inlineWikilinkText.ts
Normal file
149
src/components/inlineWikilinkText.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import {
|
||||
chipToken,
|
||||
normalizeInlineWikilinkValue,
|
||||
toInlineWikilinkTarget,
|
||||
} from './inlineWikilinkTokens'
|
||||
|
||||
export interface InlineWikilinkChip {
|
||||
entry: VaultEntry
|
||||
target: string
|
||||
}
|
||||
|
||||
export type InlineWikilinkSegment =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'chip'; chip: InlineWikilinkChip }
|
||||
|
||||
export interface ActiveWikilinkQuery {
|
||||
start: number
|
||||
query: string
|
||||
}
|
||||
|
||||
const INLINE_WIKILINK_PATTERN = /\[\[([^[\]\r\n]+?)\]\]/g
|
||||
|
||||
export function buildInlineWikilinkSegments(
|
||||
value: string,
|
||||
entries: VaultEntry[],
|
||||
): InlineWikilinkSegment[] {
|
||||
const normalizedValue = normalizeInlineWikilinkValue(value)
|
||||
const segments: InlineWikilinkSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
INLINE_WIKILINK_PATTERN.lastIndex = 0
|
||||
for (const match of normalizedValue.matchAll(INLINE_WIKILINK_PATTERN)) {
|
||||
const fullMatch = match[0]
|
||||
const target = match[1]
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
segments.push({ kind: 'text', text: normalizedValue.slice(cursor, start) })
|
||||
}
|
||||
|
||||
const entry = resolveEntry(entries, target)
|
||||
if (!entry) {
|
||||
segments.push({ kind: 'text', text: fullMatch })
|
||||
} else {
|
||||
segments.push({
|
||||
kind: 'chip',
|
||||
chip: { entry, target: toInlineWikilinkTarget(entry) },
|
||||
})
|
||||
}
|
||||
cursor = start + fullMatch.length
|
||||
}
|
||||
|
||||
if (cursor < normalizedValue.length) {
|
||||
segments.push({ kind: 'text', text: normalizedValue.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ kind: 'text', text: '' }]
|
||||
}
|
||||
|
||||
export function extractInlineWikilinkReferences(
|
||||
value: string,
|
||||
entries: VaultEntry[],
|
||||
): NoteReference[] {
|
||||
const references: NoteReference[] = []
|
||||
const seenPaths = new Set<string>()
|
||||
|
||||
for (const segment of buildInlineWikilinkSegments(value, entries)) {
|
||||
if (segment.kind !== 'chip') continue
|
||||
if (seenPaths.has(segment.chip.entry.path)) continue
|
||||
|
||||
seenPaths.add(segment.chip.entry.path)
|
||||
references.push({
|
||||
title: segment.chip.entry.title,
|
||||
path: segment.chip.entry.path,
|
||||
type: segment.chip.entry.isA,
|
||||
})
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
function hasClosedQuery(openText: string): boolean {
|
||||
return openText.includes(']]') || /[\r\n]/.test(openText)
|
||||
}
|
||||
|
||||
export function findActiveWikilinkQuery(
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
): ActiveWikilinkQuery | null {
|
||||
const clampedIndex = Math.max(0, Math.min(selectionIndex, value.length))
|
||||
const textBeforeCursor = value.slice(0, clampedIndex)
|
||||
const triggerStart = textBeforeCursor.lastIndexOf('[[')
|
||||
|
||||
if (triggerStart < 0) return null
|
||||
|
||||
const openText = textBeforeCursor.slice(triggerStart + 2)
|
||||
if (hasClosedQuery(openText)) return null
|
||||
|
||||
return { start: triggerStart, query: openText }
|
||||
}
|
||||
|
||||
export function replaceActiveWikilinkQuery(
|
||||
value: string,
|
||||
selectionIndex: number,
|
||||
target: string,
|
||||
): { value: string; nextSelectionIndex: number } | null {
|
||||
const activeQuery = findActiveWikilinkQuery(value, selectionIndex)
|
||||
if (!activeQuery) return null
|
||||
|
||||
const token = chipToken(target)
|
||||
return {
|
||||
value: value.slice(0, activeQuery.start) + token + value.slice(selectionIndex),
|
||||
nextSelectionIndex: activeQuery.start + token.length,
|
||||
}
|
||||
}
|
||||
|
||||
function segmentLength(segment: InlineWikilinkSegment): number {
|
||||
return segment.kind === 'text'
|
||||
? segment.text.length
|
||||
: chipToken(segment.chip.target).length
|
||||
}
|
||||
|
||||
export function findInlineChipDeletionRange(
|
||||
segments: InlineWikilinkSegment[],
|
||||
selectionIndex: number,
|
||||
direction: 'backward' | 'forward',
|
||||
): { start: number; end: number } | null {
|
||||
let cursor = 0
|
||||
|
||||
for (const segment of segments) {
|
||||
const nextCursor = cursor + segmentLength(segment)
|
||||
|
||||
if (segment.kind === 'chip') {
|
||||
const removePreviousChip = direction === 'backward' && selectionIndex === nextCursor
|
||||
const removeNextChip = direction === 'forward' && selectionIndex === cursor
|
||||
|
||||
if (removePreviousChip || removeNextChip) {
|
||||
return { start: cursor, end: nextCursor }
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
16
src/components/inlineWikilinkTokens.ts
Normal file
16
src/components/inlineWikilinkTokens.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export function toInlineWikilinkTarget(entry: VaultEntry): string {
|
||||
return entry.filename.replace(/\.md$/i, '')
|
||||
}
|
||||
|
||||
export function chipToken(target: string): string {
|
||||
return `[[${target}]]`
|
||||
}
|
||||
|
||||
export function normalizeInlineWikilinkValue(value: string): string {
|
||||
return value
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.replace(/\u200B/g, '')
|
||||
.replace(/\r?\n/g, ' ')
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useMemo, useEffect, useCallback, type RefObject } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../../constants/appStorage'
|
||||
import { buildTypeEntryMap } from '../../utils/typeColors'
|
||||
import { buildDynamicSections, sortSections } from '../../utils/sidebarSections'
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'laputa:sidebar-collapsed'
|
||||
|
||||
export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
|
||||
|
||||
export function useOutsideClick(ref: RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
|
||||
@@ -34,7 +33,7 @@ export function useSidebarSections(entries: VaultEntry[]) {
|
||||
|
||||
function loadCollapsedState(): Record<SidebarGroupKey, boolean> {
|
||||
try {
|
||||
const raw = localStorage.getItem(SIDEBAR_COLLAPSED_KEY)
|
||||
const raw = getAppStorageItem('sidebarCollapsed')
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch {
|
||||
// Ignore localStorage failures and fall back to defaults.
|
||||
@@ -48,7 +47,8 @@ export function useSidebarCollapsed() {
|
||||
const toggle = useCallback((key: SidebarGroupKey) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = { ...prev, [key]: !prev[key] }
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(next))
|
||||
localStorage.setItem(APP_STORAGE_KEYS.sidebarCollapsed, JSON.stringify(next))
|
||||
localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.sidebarCollapsed)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -24,8 +24,7 @@ interface StatusBarPrimarySectionProps {
|
||||
vaults: VaultOption[]
|
||||
onSwitchVault: (path: string) => void
|
||||
onOpenLocalFolder?: () => void
|
||||
onConnectGitHub?: () => void
|
||||
hasGitHub?: boolean
|
||||
onCloneVault?: () => void
|
||||
onClickPending?: () => void
|
||||
onClickPulse?: () => void
|
||||
onCommitPush?: () => void
|
||||
@@ -61,8 +60,7 @@ export function StatusBarPrimarySection({
|
||||
vaults,
|
||||
onSwitchVault,
|
||||
onOpenLocalFolder,
|
||||
onConnectGitHub,
|
||||
hasGitHub,
|
||||
onCloneVault,
|
||||
onClickPending,
|
||||
onClickPulse,
|
||||
onCommitPush,
|
||||
@@ -90,8 +88,7 @@ export function StatusBarPrimarySection({
|
||||
vaultPath={vaultPath}
|
||||
onSwitchVault={onSwitchVault}
|
||||
onOpenLocalFolder={onOpenLocalFolder}
|
||||
onConnectGitHub={onConnectGitHub}
|
||||
hasGitHub={hasGitHub}
|
||||
onCloneVault={onCloneVault}
|
||||
onRemoveVault={onRemoveVault}
|
||||
/>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { AlertTriangle, Check, FolderOpen, Github, X } from 'lucide-react'
|
||||
import { AlertTriangle, Check, FolderOpen, GitBranch, X } from 'lucide-react'
|
||||
import type { VaultOption } from './types'
|
||||
import { useDismissibleLayer } from './useDismissibleLayer'
|
||||
|
||||
@@ -9,8 +9,7 @@ interface VaultMenuProps {
|
||||
vaultPath: string
|
||||
onSwitchVault: (path: string) => void
|
||||
onOpenLocalFolder?: () => void
|
||||
onConnectGitHub?: () => void
|
||||
hasGitHub?: boolean
|
||||
onCloneVault?: () => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
}
|
||||
|
||||
@@ -142,8 +141,7 @@ export function VaultMenu({
|
||||
vaultPath,
|
||||
onSwitchVault,
|
||||
onOpenLocalFolder,
|
||||
onConnectGitHub,
|
||||
hasGitHub,
|
||||
onCloneVault,
|
||||
onRemoveVault,
|
||||
}: VaultMenuProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -166,19 +164,18 @@ export function VaultMenu({
|
||||
})
|
||||
}
|
||||
|
||||
if (onConnectGitHub) {
|
||||
if (onCloneVault) {
|
||||
items.push({
|
||||
key: 'connect-github',
|
||||
icon: <Github size={12} />,
|
||||
label: 'Connect GitHub repo',
|
||||
testId: 'vault-menu-connect-github',
|
||||
accent: !hasGitHub,
|
||||
onClick: onConnectGitHub,
|
||||
key: 'clone-git',
|
||||
icon: <GitBranch size={12} />,
|
||||
label: 'Clone Git repo',
|
||||
testId: 'vault-menu-clone-git',
|
||||
onClick: onCloneVault,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}, [hasGitHub, onConnectGitHub, onOpenLocalFolder])
|
||||
}, [onCloneVault, onOpenLocalFolder])
|
||||
|
||||
return (
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
|
||||
22
src/components/tableResizeState.ts
Normal file
22
src/components/tableResizeState.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
53
src/components/useAiPanelContextSnapshot.ts
Normal file
53
src/components/useAiPanelContextSnapshot.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
buildContextSnapshot,
|
||||
collectLinkedEntries,
|
||||
type NoteListItem,
|
||||
} from '../utils/ai-context'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
|
||||
interface UseAiPanelContextSnapshotArgs {
|
||||
activeEntry?: VaultEntry | null
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
input: string
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
}
|
||||
|
||||
export function useAiPanelContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
input,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
}: UseAiPanelContextSnapshotArgs) {
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const draftReferences = useMemo(
|
||||
() => extractInlineWikilinkReferences(input, entries ?? []),
|
||||
[entries, input],
|
||||
)
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: draftReferences.length > 0 ? draftReferences : undefined,
|
||||
})
|
||||
}, [activeEntry, activeNoteContent, draftReferences, entries, noteList, noteListFilter, openTabs])
|
||||
|
||||
return { linkedEntries, contextPrompt }
|
||||
}
|
||||
78
src/components/useAiPanelController.ts
Normal file
78
src/components/useAiPanelController.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useAiAgent, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type NoteListItem,
|
||||
type NoteReference,
|
||||
} from '../utils/ai-context'
|
||||
import { useAiPanelContextSnapshot } from './useAiPanelContextSnapshot'
|
||||
|
||||
interface UseAiPanelControllerArgs {
|
||||
vaultPath: string
|
||||
activeEntry?: VaultEntry | null
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
export function useAiPanelController({
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: UseAiPanelControllerArgs) {
|
||||
const [input, setInput] = useState('')
|
||||
const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
input,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
})
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
agent.sendMessage(text, references)
|
||||
setInput('')
|
||||
}, [agent, isActive])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
return {
|
||||
agent,
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user