Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ae06cc301d
|
|||
|
a0804ed32a
|
|||
|
daedbfd865
|
|||
| 7093e58fe4 | |||
|
cab759ec61
|
|||
| e45a1a1c98 | |||
| edc863e020 | |||
| b006f571bf | |||
| ea3cc8b26c | |||
| 2bb541fba6 | |||
|
bebf1552a6
|
|||
| b3d79a82ef | |||
| 4c46d4c8fd | |||
| 852a4d6661 | |||
|
bbeff7ae2e
|
|||
| 3f30997f0e | |||
| 06810537a9 | |||
| 94991796be | |||
| 947e56ef41 | |||
| 9fe4e8a48a | |||
|
bc596867d4
|
|||
| e877f4aaf2 | |||
| 377f81d978 | |||
| d83697e5cf | |||
| 2d3adcab1c | |||
| 70fcaa8650 | |||
| a8f98406e1 | |||
| 0065bb4afc | |||
| ac84366716 | |||
| 2220c26c5e | |||
| c241544743 | |||
| bd04328e40 |
@@ -8,3 +8,4 @@
|
|||||||
*.jpg binary
|
*.jpg binary
|
||||||
*.icons binary
|
*.icons binary
|
||||||
*.ico binary
|
*.ico binary
|
||||||
|
*.icns binary
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
description: Something isn't working as expected? Let us know!
|
description: Something isn't working as expected? Let us know!
|
||||||
title: '[BUG] - '
|
title: "[BUG] - "
|
||||||
labels:
|
labels:
|
||||||
- "status/awaiting triage"
|
- "status/awaiting triage"
|
||||||
body:
|
body:
|
||||||
@@ -50,7 +50,7 @@ body:
|
|||||||
description: The operating system you are using, including the version/build number.
|
description: The operating system you are using, including the version/build number.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
# Remove this section for non-web apps.
|
# Remove this section for non-web apps.
|
||||||
- type: input
|
- type: input
|
||||||
id: browser
|
id: browser
|
||||||
attributes:
|
attributes:
|
||||||
@@ -66,4 +66,3 @@ body:
|
|||||||
- No
|
- No
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: 💭 Feature Proposal
|
name: 💭 Feature Proposal
|
||||||
description: Have an idea for how we can improve? Share it here!
|
description: Have an idea for how we can improve? Share it here!
|
||||||
title: '[FEAT] - '
|
title: "[FEAT] - "
|
||||||
labels:
|
labels:
|
||||||
- "status/awaiting triage"
|
- "status/awaiting triage"
|
||||||
body:
|
body:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: ❓ Other Issue
|
name: ❓ Other Issue
|
||||||
description: I have something that is neither a bug nor a feature request.
|
description: I have something that is neither a bug nor a feature request.
|
||||||
title: '[OTHER] - '
|
title: "[OTHER] - "
|
||||||
labels:
|
labels:
|
||||||
- "status/awaiting triage"
|
- "status/awaiting triage"
|
||||||
body:
|
body:
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
name: Lint & Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libayatana-appindicator3-dev
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Run Prettier check
|
||||||
|
run: pnpm format:check
|
||||||
|
|
||||||
|
- name: Run Svelte Check
|
||||||
|
run: pnpm check
|
||||||
|
|
||||||
|
- name: Run frontend tests with coverage
|
||||||
|
run: pnpm test:coverage
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy, llvm-tools-preview
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/bin/
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
src-tauri/target/
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Install cargo-llvm-cov
|
||||||
|
run: cargo install cargo-llvm-cov --locked
|
||||||
|
|
||||||
|
- name: Run Clippy
|
||||||
|
working-directory: src-tauri
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: Run Rust tests with coverage
|
||||||
|
working-directory: src-tauri
|
||||||
|
run: cargo llvm-cov --fail-under-lines 50
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
name: Build Linux
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
xdg-utils
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/bin/
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
src-tauri/target/
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build Linux
|
||||||
|
run: pnpm build:linux
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
name: Build Windows (cross-compile)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Linux dependencies for cross-compilation
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
clang \
|
||||||
|
lld \
|
||||||
|
llvm \
|
||||||
|
nsis
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Install cargo-xwin
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://github.com/rust-cross/cargo-xwin/releases/download/v0.20.2/cargo-xwin-v0.20.2.x86_64-unknown-linux-musl.tar.gz | tar xz
|
||||||
|
sudo mv cargo-xwin /usr/local/bin/
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/bin/
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
src-tauri/target/
|
||||||
|
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build Windows
|
||||||
|
run: pnpm build:windows
|
||||||
@@ -2,11 +2,11 @@ name: Security Scan and Upload
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [main]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 0 * * 1'
|
- cron: "0 0 * * 1"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ node_modules
|
|||||||
!.env.example
|
!.env.example
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
/coverage
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": ["svelte.svelte-vscode", "tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||||
"svelte.svelte-vscode",
|
|
||||||
"tauri-apps.tauri-vscode",
|
|
||||||
"rust-lang.rust-analyzer"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +1,14 @@
|
|||||||
# Hikari Desktop
|
# hikari-desktop
|
||||||
|
|
||||||
A Linux desktop application that wraps Claude Code with an anime girl character that reacts to Claude's activities in real-time.
|
Desktop companion application featuring Hikari.
|
||||||
|
|
||||||
## Features
|
## Live Version
|
||||||
|
|
||||||
- Visual character that reflects Claude's current state (thinking, typing, searching, coding, etc.)
|
This page is currently deployed. [View the live website.](https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/releases)
|
||||||
- Terminal-style output display
|
|
||||||
- Permission prompts with approve/deny interface
|
|
||||||
- Real-time state detection from Claude Code's NDJSON stream
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### 1. Install Claude Code
|
|
||||||
|
|
||||||
Hikari Desktop requires Claude Code to be installed and authenticated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g @anthropic-ai/claude-code
|
|
||||||
claude # Follow the prompts to authenticate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Install Runtime Dependencies
|
|
||||||
|
|
||||||
**Debian/Ubuntu:**
|
|
||||||
```bash
|
|
||||||
sudo apt install libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1 xdg-utils
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fedora:**
|
|
||||||
```bash
|
|
||||||
sudo dnf install webkit2gtk4.1 gtk3 libappindicator-gtk3 xdg-utils
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arch Linux:**
|
|
||||||
```bash
|
|
||||||
sudo pacman -S webkit2gtk-4.1 gtk3 libappindicator-gtk3 xdg-utils
|
|
||||||
```
|
|
||||||
|
|
||||||
| Package | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| webkit2gtk-4.1 | WebView rendering (app UI) |
|
|
||||||
| gtk3 | Window management and native widgets |
|
|
||||||
| libappindicator | System tray support |
|
|
||||||
| xdg-utils | Opening URLs/files with default applications |
|
|
||||||
|
|
||||||
### 3. Install Hikari Desktop
|
|
||||||
|
|
||||||
Download the latest release for your distribution:
|
|
||||||
|
|
||||||
**AppImage** (any distro):
|
|
||||||
```bash
|
|
||||||
chmod +x hikari-desktop_*.AppImage
|
|
||||||
./hikari-desktop_*.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
**Debian/Ubuntu:**
|
|
||||||
```bash
|
|
||||||
sudo dpkg -i hikari-desktop_*.deb
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fedora:**
|
|
||||||
```bash
|
|
||||||
sudo rpm -i hikari-desktop-*.rpm
|
|
||||||
```
|
|
||||||
|
|
||||||
## Character States
|
|
||||||
|
|
||||||
| State | Trigger |
|
|
||||||
|-------|---------|
|
|
||||||
| Idle | Waiting for user input |
|
|
||||||
| Thinking | Processing/API call in progress |
|
|
||||||
| Typing | Streaming text output |
|
|
||||||
| Searching | Using Read/Glob/Grep tools |
|
|
||||||
| Coding | Using Edit/Write tools |
|
|
||||||
| MCP | Running MCP tool calls |
|
|
||||||
| Permission | Permission prompt needed |
|
|
||||||
| Success | Task completed |
|
|
||||||
| Error | Error occurred |
|
|
||||||
|
|
||||||
## Building from Source
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js and pnpm
|
|
||||||
- Rust toolchain
|
|
||||||
|
|
||||||
### Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Development mode
|
|
||||||
pnpm run dev
|
|
||||||
|
|
||||||
# Build for Linux
|
|
||||||
pnpm tauri build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Linux (Tauri App)
|
|
||||||
├── Svelte Frontend
|
|
||||||
│ ├── AnimeGirl (sprites + animations)
|
|
||||||
│ ├── Terminal (output display)
|
|
||||||
│ ├── InputBar (user input)
|
|
||||||
│ └── PermissionModal (approve/deny)
|
|
||||||
└── Rust Backend
|
|
||||||
├── Process Manager (spawn & communicate)
|
|
||||||
└── State Parser (NDJSON → character state)
|
|
||||||
│
|
|
||||||
│ stdin/stdout (NDJSON stream)
|
|
||||||
▼
|
|
||||||
claude -p --output-format stream-json --input-format stream-json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Tauri 2.x** - Desktop framework with Rust backend
|
|
||||||
- **Svelte 5** - Reactive frontend with runes
|
|
||||||
- **Tailwind CSS** - Styling
|
|
||||||
- **Tokio** - Async runtime for process management
|
|
||||||
|
|
||||||
## Feedback and Bugs
|
## Feedback and Bugs
|
||||||
|
|
||||||
If you have feedback or a bug report, please feel free to open a ticket request in our [Discord](https://chat.nhcarrigan.com)!
|
If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -143,4 +26,4 @@ Copyright held by Naomi Carrigan.
|
|||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`.
|
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to run a command and check its status
|
||||||
|
run_check() {
|
||||||
|
local desc=$1
|
||||||
|
local cmd=$2
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}Running: ${desc}${NC}"
|
||||||
|
echo -e "${YELLOW}Command: ${cmd}${NC}"
|
||||||
|
|
||||||
|
if eval "$cmd"; then
|
||||||
|
echo -e "${GREEN}✓ ${desc} passed${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ ${desc} failed${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Track if any checks fail
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
|
||||||
|
|
||||||
|
# Frontend checks
|
||||||
|
run_check "Frontend lint" "pnpm lint" || failed=1
|
||||||
|
run_check "Frontend format check" "pnpm format:check" || failed=1
|
||||||
|
run_check "Frontend type check" "pnpm check" || failed=1
|
||||||
|
run_check "Frontend tests" "pnpm test" || failed=1
|
||||||
|
|
||||||
|
# Backend checks
|
||||||
|
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1
|
||||||
|
run_check "Backend tests" "cargo test" || failed=1
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo -e "\n${YELLOW}========================================${NC}"
|
||||||
|
if [ $failed -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✨ All checks passed! The code is looking great!${NC}"
|
||||||
|
echo -e "${GREEN} Naomi would be so proud of us! 💖${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Some checks failed. Let's fix them together!${NC}"
|
||||||
|
echo -e "${RED} Don't worry, we'll get through this! 💪${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import svelte from "eslint-plugin-svelte";
|
||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...svelte.configs["flat/recommended"],
|
||||||
|
prettier,
|
||||||
|
...svelte.configs["flat/prettier"],
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/", "coverage/"],
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "0.1.0",
|
"version": "1.1.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,25 +9,86 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"build:linux": "tauri build",
|
||||||
|
"build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc",
|
||||||
|
"build:all": "pnpm build:linux && pnpm build:windows",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:backend": "cd src-tauri && cargo test",
|
||||||
|
"test:backend:coverage": "cd src-tauri && cargo llvm-cov --text",
|
||||||
|
"test:all": "pnpm test && pnpm test:backend",
|
||||||
|
"coverage:all": "pnpm test:coverage && pnpm test:backend:coverage",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "6.8.1",
|
||||||
|
"@codemirror/lang-angular": "^0.1.4",
|
||||||
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-go": "^6.0.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-java": "^6.0.2",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-less": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/lang-php": "^6.0.2",
|
||||||
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
|
"@codemirror/lang-rust": "^6.0.2",
|
||||||
|
"@codemirror/lang-sass": "^6.0.2",
|
||||||
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
|
"@codemirror/lang-vue": "^0.1.3",
|
||||||
|
"@codemirror/lang-wast": "^6.0.2",
|
||||||
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
|
"@codemirror/language": "^6.12.1",
|
||||||
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
|
"@codemirror/state": "^6.5.4",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.39.11",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
|
"@tauri-apps/plugin-notification": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.4"
|
"@tauri-apps/plugin-os": "^2",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||||
|
"@tauri-apps/plugin-store": "^2",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"marked": "^17.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.2",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "^2.9.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/svelte": "^5.3.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
|
"globals": "^17.0.0",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
|
"prettier": "^3.8.0",
|
||||||
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.3"
|
"typescript-eslint": "^8.53.0",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "0.1.0"
|
version = "1.1.1"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
@@ -22,4 +22,24 @@ serde_json = "1"
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
tauri-plugin-store = "2.4.2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
tauri-plugin-http = "2"
|
||||||
|
tauri-plugin-clipboard-manager = "2"
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
|
tempfile = "3"
|
||||||
|
semver = "1"
|
||||||
|
chrono = { version = "0.4.43", features = ["serde"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows = { version = "0.62", features = [
|
||||||
|
"Data_Xml_Dom",
|
||||||
|
"UI_Notifications",
|
||||||
|
"Win32_System_Com",
|
||||||
|
"Win32_Foundation",
|
||||||
|
] }
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,27 @@
|
|||||||
"opener:default",
|
"opener:default",
|
||||||
"shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
"shell:allow-stdin-write",
|
"shell:allow-stdin-write",
|
||||||
"shell:allow-kill"
|
"shell:allow-kill",
|
||||||
|
"notification:default",
|
||||||
|
"notification:allow-is-permission-granted",
|
||||||
|
"notification:allow-request-permission",
|
||||||
|
"notification:allow-notify",
|
||||||
|
"clipboard-manager:default",
|
||||||
|
"clipboard-manager:allow-read-image",
|
||||||
|
"core:tray:default",
|
||||||
|
"fs:default",
|
||||||
|
"fs:allow-read-text-file",
|
||||||
|
"fs:allow-write-text-file",
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-file",
|
||||||
|
"allow": [{ "path": "**" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-write-file",
|
||||||
|
"allow": [{ "path": "**" }]
|
||||||
|
},
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-always-on-top",
|
||||||
|
"core:window:allow-inner-size"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,181 @@
|
|||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::config::ClaudeStartOptions;
|
||||||
|
use crate::provider_bridge::ProviderBridge;
|
||||||
|
use crate::stats::UsageStats;
|
||||||
|
|
||||||
|
pub struct BridgeManager {
|
||||||
|
bridges: HashMap<String, ProviderBridge>,
|
||||||
|
app_handle: Option<AppHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
BridgeManager {
|
||||||
|
bridges: HashMap::new(),
|
||||||
|
app_handle: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_app_handle(&mut self, app: AppHandle) {
|
||||||
|
self.app_handle = Some(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_claude(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: &str,
|
||||||
|
options: ClaudeStartOptions,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Check if a bridge already exists and is running for this conversation
|
||||||
|
if self
|
||||||
|
.bridges
|
||||||
|
.get(conversation_id)
|
||||||
|
.map(|b| b.is_running())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Err("Claude is already running for this conversation".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = self
|
||||||
|
.app_handle
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "App handle not set".to_string())?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Check if existing bridge matches the requested provider type
|
||||||
|
// If provider type changed, create a new bridge
|
||||||
|
let should_recreate = self.bridges.get(conversation_id).map_or(false, |bridge| {
|
||||||
|
bridge.provider_type() != options.provider_type
|
||||||
|
});
|
||||||
|
|
||||||
|
if should_recreate {
|
||||||
|
// Remove existing bridge if provider type changed
|
||||||
|
self.bridges.remove(conversation_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse existing bridge if it exists (preserves stats across reconnects)
|
||||||
|
// Only create a new bridge if one doesn't exist for this conversation
|
||||||
|
let provider_type = options.provider_type;
|
||||||
|
let bridge = self
|
||||||
|
.bridges
|
||||||
|
.entry(conversation_id.to_string())
|
||||||
|
.or_insert_with(|| ProviderBridge::new(provider_type, conversation_id.to_string()));
|
||||||
|
|
||||||
|
// Start the Claude process
|
||||||
|
bridge.start(app, options)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> {
|
||||||
|
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
|
||||||
|
let app = self
|
||||||
|
.app_handle
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "App handle not set".to_string())?;
|
||||||
|
bridge.stop(app);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("No Claude instance found for this conversation".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> {
|
||||||
|
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
|
||||||
|
let app = self
|
||||||
|
.app_handle
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "App handle not set".to_string())?;
|
||||||
|
bridge.interrupt(app)
|
||||||
|
} else {
|
||||||
|
Err("No Claude instance found for this conversation".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_prompt(&mut self, conversation_id: &str, message: String) -> Result<(), String> {
|
||||||
|
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
|
||||||
|
bridge.send_message(&message)
|
||||||
|
} else {
|
||||||
|
Err("No Claude instance found for this conversation".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_tool_result(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: &str,
|
||||||
|
tool_use_id: &str,
|
||||||
|
result: serde_json::Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
|
||||||
|
bridge.send_tool_result(tool_use_id, result)
|
||||||
|
} else {
|
||||||
|
Err("No Claude instance found for this conversation".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_claude_running(&self, conversation_id: &str) -> bool {
|
||||||
|
self.bridges
|
||||||
|
.get(conversation_id)
|
||||||
|
.map(|b| b.is_running())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> {
|
||||||
|
self.bridges
|
||||||
|
.get(conversation_id)
|
||||||
|
.map(|b| b.get_working_directory().to_string())
|
||||||
|
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_usage_stats(&self, conversation_id: &str) -> Result<UsageStats, String> {
|
||||||
|
self.bridges
|
||||||
|
.get(conversation_id)
|
||||||
|
.map(|b| b.get_stats())
|
||||||
|
.ok_or_else(|| "No Claude instance found for this conversation".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn cleanup_stopped_bridges(&mut self) {
|
||||||
|
// Remove bridges that are no longer running
|
||||||
|
self.bridges.retain(|_, bridge| bridge.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn stop_all(&mut self) {
|
||||||
|
if let Some(app) = &self.app_handle {
|
||||||
|
for (_, bridge) in self.bridges.iter_mut() {
|
||||||
|
bridge.stop(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.bridges.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_active_conversations(&self) -> Vec<String> {
|
||||||
|
self.bridges
|
||||||
|
.keys()
|
||||||
|
.filter(|id| {
|
||||||
|
self.bridges
|
||||||
|
.get(*id)
|
||||||
|
.map(|b| b.is_running())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BridgeManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
|
||||||
|
|
||||||
|
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
|
||||||
|
Arc::new(Mutex::new(BridgeManager::new()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
// Clipboard history module for tracking and managing copied code snippets
|
||||||
|
// Implements issue #25 - Clipboard History feature
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const STORE_FILE: &str = "hikari-clipboard.json";
|
||||||
|
const HISTORY_KEY: &str = "clipboard_history";
|
||||||
|
const MAX_HISTORY_SIZE: usize = 100;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ClipboardEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub content: String,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub source: Option<String>,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipboardEntry {
|
||||||
|
pub fn new(content: String, language: Option<String>, source: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
content,
|
||||||
|
language,
|
||||||
|
source,
|
||||||
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
|
is_pinned: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
struct ClipboardHistory {
|
||||||
|
entries: Vec<ClipboardEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track last clipboard content to avoid duplicates
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ClipboardState {
|
||||||
|
last_content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static CLIPBOARD_STATE: Mutex<ClipboardState> = Mutex::new(ClipboardState { last_content: None });
|
||||||
|
|
||||||
|
fn load_history(app: &tauri::AppHandle) -> ClipboardHistory {
|
||||||
|
let store = app.store(STORE_FILE).ok();
|
||||||
|
store
|
||||||
|
.and_then(|s| s.get(HISTORY_KEY))
|
||||||
|
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_history(app: &tauri::AppHandle, history: &ClipboardHistory) -> Result<(), String> {
|
||||||
|
let store = app.store(STORE_FILE).map_err(|e| e.to_string())?;
|
||||||
|
store.set(
|
||||||
|
HISTORY_KEY,
|
||||||
|
serde_json::to_value(history).map_err(|e| e.to_string())?,
|
||||||
|
);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all clipboard entries, optionally filtered by language
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_clipboard_entries(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
language: Option<String>,
|
||||||
|
) -> Result<Vec<ClipboardEntry>, String> {
|
||||||
|
let history = load_history(&app);
|
||||||
|
let entries = if let Some(lang) = language {
|
||||||
|
history
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| e.language.as_ref() == Some(&lang))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
history.entries
|
||||||
|
};
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture current clipboard content and add to history
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn capture_clipboard(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
content: String,
|
||||||
|
language: Option<String>,
|
||||||
|
source: Option<String>,
|
||||||
|
) -> Result<ClipboardEntry, String> {
|
||||||
|
// Check for duplicate (same content as last capture)
|
||||||
|
{
|
||||||
|
let mut state = CLIPBOARD_STATE.lock().map_err(|e| e.to_string())?;
|
||||||
|
if state.last_content.as_ref() == Some(&content) {
|
||||||
|
// Return existing entry if content is the same
|
||||||
|
let history = load_history(&app);
|
||||||
|
if let Some(entry) = history.entries.first() {
|
||||||
|
if entry.content == content {
|
||||||
|
return Ok(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.last_content = Some(content.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = ClipboardEntry::new(content, language, source);
|
||||||
|
let mut history = load_history(&app);
|
||||||
|
|
||||||
|
// Add to front of history
|
||||||
|
history.entries.insert(0, entry.clone());
|
||||||
|
|
||||||
|
// Enforce max size (keep pinned entries)
|
||||||
|
let mut pinned: Vec<ClipboardEntry> = history
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.is_pinned)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let mut unpinned: Vec<ClipboardEntry> = history
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| !e.is_pinned)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Trim unpinned entries if over max size
|
||||||
|
if unpinned.len() + pinned.len() > MAX_HISTORY_SIZE {
|
||||||
|
let max_unpinned = MAX_HISTORY_SIZE.saturating_sub(pinned.len());
|
||||||
|
unpinned.truncate(max_unpinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge back, pinned first then unpinned
|
||||||
|
pinned.extend(unpinned);
|
||||||
|
history.entries = pinned;
|
||||||
|
|
||||||
|
// Sort by timestamp descending (newest first), pinned entries stay at top
|
||||||
|
history.entries.sort_by(|a, b| {
|
||||||
|
if a.is_pinned && !b.is_pinned {
|
||||||
|
std::cmp::Ordering::Less
|
||||||
|
} else if !a.is_pinned && b.is_pinned {
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
} else {
|
||||||
|
b.timestamp.cmp(&a.timestamp)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
save_history(&app, &history)?;
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a clipboard entry by ID
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_clipboard_entry(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||||
|
let mut history = load_history(&app);
|
||||||
|
history.entries.retain(|e| e.id != id);
|
||||||
|
save_history(&app, &history)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle pin status of an entry
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn toggle_pin_clipboard_entry(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
id: String,
|
||||||
|
) -> Result<ClipboardEntry, String> {
|
||||||
|
let mut history = load_history(&app);
|
||||||
|
let entry = history
|
||||||
|
.entries
|
||||||
|
.iter_mut()
|
||||||
|
.find(|e| e.id == id)
|
||||||
|
.ok_or("Entry not found")?;
|
||||||
|
|
||||||
|
entry.is_pinned = !entry.is_pinned;
|
||||||
|
let updated_entry = entry.clone();
|
||||||
|
|
||||||
|
// Re-sort to move pinned entries to top
|
||||||
|
history.entries.sort_by(|a, b| {
|
||||||
|
if a.is_pinned && !b.is_pinned {
|
||||||
|
std::cmp::Ordering::Less
|
||||||
|
} else if !a.is_pinned && b.is_pinned {
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
} else {
|
||||||
|
b.timestamp.cmp(&a.timestamp)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
save_history(&app, &history)?;
|
||||||
|
Ok(updated_entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all non-pinned entries
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_clipboard_history(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let mut history = load_history(&app);
|
||||||
|
history.entries.retain(|e| e.is_pinned);
|
||||||
|
save_history(&app, &history)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search clipboard entries by content
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn search_clipboard_entries(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
query: String,
|
||||||
|
) -> Result<Vec<ClipboardEntry>, String> {
|
||||||
|
let history = load_history(&app);
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let entries = history
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| {
|
||||||
|
e.content.to_lowercase().contains(&query_lower)
|
||||||
|
|| e.language
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|l| l.to_lowercase().contains(&query_lower))
|
||||||
|
|| e.source
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|s| s.to_lowercase().contains(&query_lower))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all unique languages from history
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_clipboard_languages(app: tauri::AppHandle) -> Result<Vec<String>, String> {
|
||||||
|
let history = load_history(&app);
|
||||||
|
let mut languages: Vec<String> = history
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.language.clone())
|
||||||
|
.collect();
|
||||||
|
languages.sort();
|
||||||
|
languages.dedup();
|
||||||
|
Ok(languages)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the language of an entry
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_clipboard_language(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
id: String,
|
||||||
|
language: Option<String>,
|
||||||
|
) -> Result<ClipboardEntry, String> {
|
||||||
|
let mut history = load_history(&app);
|
||||||
|
let entry = history
|
||||||
|
.entries
|
||||||
|
.iter_mut()
|
||||||
|
.find(|e| e.id == id)
|
||||||
|
.ok_or("Entry not found")?;
|
||||||
|
|
||||||
|
entry.language = language;
|
||||||
|
let updated_entry = entry.clone();
|
||||||
|
|
||||||
|
save_history(&app, &history)?;
|
||||||
|
Ok(updated_entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ==================== ClipboardEntry tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_entry_new() {
|
||||||
|
let entry = ClipboardEntry::new(
|
||||||
|
"let x = 42;".to_string(),
|
||||||
|
Some("rust".to_string()),
|
||||||
|
Some("main.rs".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(entry.content, "let x = 42;");
|
||||||
|
assert_eq!(entry.language, Some("rust".to_string()));
|
||||||
|
assert_eq!(entry.source, Some("main.rs".to_string()));
|
||||||
|
assert!(!entry.is_pinned);
|
||||||
|
assert!(!entry.id.is_empty());
|
||||||
|
assert!(!entry.timestamp.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_entry_new_without_optional_fields() {
|
||||||
|
let entry = ClipboardEntry::new("some content".to_string(), None, None);
|
||||||
|
|
||||||
|
assert_eq!(entry.content, "some content");
|
||||||
|
assert!(entry.language.is_none());
|
||||||
|
assert!(entry.source.is_none());
|
||||||
|
assert!(!entry.is_pinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_entry_unique_ids() {
|
||||||
|
let entry1 = ClipboardEntry::new("content1".to_string(), None, None);
|
||||||
|
let entry2 = ClipboardEntry::new("content2".to_string(), None, None);
|
||||||
|
|
||||||
|
assert_ne!(entry1.id, entry2.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_entry_serialization() {
|
||||||
|
let entry = ClipboardEntry::new(
|
||||||
|
"fn main() {}".to_string(),
|
||||||
|
Some("rust".to_string()),
|
||||||
|
Some("lib.rs".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&entry).unwrap();
|
||||||
|
assert!(json.contains("fn main() {}"));
|
||||||
|
assert!(json.contains("rust"));
|
||||||
|
assert!(json.contains("lib.rs"));
|
||||||
|
assert!(json.contains("is_pinned"));
|
||||||
|
|
||||||
|
let deserialized: ClipboardEntry = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.content, entry.content);
|
||||||
|
assert_eq!(deserialized.language, entry.language);
|
||||||
|
assert_eq!(deserialized.source, entry.source);
|
||||||
|
assert_eq!(deserialized.id, entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_entry_clone() {
|
||||||
|
let entry = ClipboardEntry::new(
|
||||||
|
"original".to_string(),
|
||||||
|
Some("python".to_string()),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cloned = entry.clone();
|
||||||
|
assert_eq!(cloned.content, entry.content);
|
||||||
|
assert_eq!(cloned.id, entry.id);
|
||||||
|
assert_eq!(cloned.language, entry.language);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_entry_timestamp_is_rfc3339() {
|
||||||
|
let entry = ClipboardEntry::new("test".to_string(), None, None);
|
||||||
|
|
||||||
|
// RFC3339 timestamp should parse successfully
|
||||||
|
let parsed = chrono::DateTime::parse_from_rfc3339(&entry.timestamp);
|
||||||
|
assert!(parsed.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ClipboardHistory tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_history_default() {
|
||||||
|
let history = ClipboardHistory::default();
|
||||||
|
assert!(history.entries.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_history_serialization() {
|
||||||
|
let mut history = ClipboardHistory::default();
|
||||||
|
history.entries.push(ClipboardEntry::new(
|
||||||
|
"entry1".to_string(),
|
||||||
|
Some("js".to_string()),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
history.entries.push(ClipboardEntry::new(
|
||||||
|
"entry2".to_string(),
|
||||||
|
None,
|
||||||
|
Some("file.txt".to_string()),
|
||||||
|
));
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&history).unwrap();
|
||||||
|
assert!(json.contains("entry1"));
|
||||||
|
assert!(json.contains("entry2"));
|
||||||
|
assert!(json.contains("js"));
|
||||||
|
assert!(json.contains("file.txt"));
|
||||||
|
|
||||||
|
let deserialized: ClipboardHistory = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.entries.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_history_entries_order() {
|
||||||
|
let mut history = ClipboardHistory::default();
|
||||||
|
|
||||||
|
history.entries.push(ClipboardEntry::new("first".to_string(), None, None));
|
||||||
|
history.entries.push(ClipboardEntry::new("second".to_string(), None, None));
|
||||||
|
history.entries.push(ClipboardEntry::new("third".to_string(), None, None));
|
||||||
|
|
||||||
|
assert_eq!(history.entries[0].content, "first");
|
||||||
|
assert_eq!(history.entries[1].content, "second");
|
||||||
|
assert_eq!(history.entries[2].content, "third");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ClipboardState tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_state_default() {
|
||||||
|
let state = ClipboardState::default();
|
||||||
|
assert!(state.last_content.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clipboard_state_with_content() {
|
||||||
|
let state = ClipboardState {
|
||||||
|
last_content: Some("cached content".to_string()),
|
||||||
|
};
|
||||||
|
assert_eq!(state.last_content, Some("cached content".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MAX_HISTORY_SIZE constant test ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_history_size_is_reasonable() {
|
||||||
|
assert_eq!(MAX_HISTORY_SIZE, 100);
|
||||||
|
// Compile-time assertions for constant bounds
|
||||||
|
const _: () = assert!(MAX_HISTORY_SIZE > 0);
|
||||||
|
const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Pinned entry sorting tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::useless_vec)]
|
||||||
|
fn test_pinned_entries_sorting() {
|
||||||
|
let mut entries = vec![
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "1".to_string(),
|
||||||
|
content: "unpinned older".to_string(),
|
||||||
|
language: None,
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "2".to_string(),
|
||||||
|
content: "pinned".to_string(),
|
||||||
|
language: None,
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
||||||
|
is_pinned: true,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "3".to_string(),
|
||||||
|
content: "unpinned newer".to_string(),
|
||||||
|
language: None,
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply the same sorting logic as used in the module
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
if a.is_pinned && !b.is_pinned {
|
||||||
|
std::cmp::Ordering::Less
|
||||||
|
} else if !a.is_pinned && b.is_pinned {
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
} else {
|
||||||
|
b.timestamp.cmp(&a.timestamp)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pinned should be first
|
||||||
|
assert!(entries[0].is_pinned);
|
||||||
|
assert_eq!(entries[0].id, "2");
|
||||||
|
|
||||||
|
// Then unpinned sorted by timestamp descending (newest first)
|
||||||
|
assert_eq!(entries[1].id, "3"); // newer unpinned
|
||||||
|
assert_eq!(entries[2].id, "1"); // older unpinned
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::useless_vec)]
|
||||||
|
fn test_multiple_pinned_entries_sorting() {
|
||||||
|
let mut entries = vec![
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "1".to_string(),
|
||||||
|
content: "pinned older".to_string(),
|
||||||
|
language: None,
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
is_pinned: true,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "2".to_string(),
|
||||||
|
content: "unpinned".to_string(),
|
||||||
|
language: None,
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "3".to_string(),
|
||||||
|
content: "pinned newer".to_string(),
|
||||||
|
language: None,
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
||||||
|
is_pinned: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
if a.is_pinned && !b.is_pinned {
|
||||||
|
std::cmp::Ordering::Less
|
||||||
|
} else if !a.is_pinned && b.is_pinned {
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
} else {
|
||||||
|
b.timestamp.cmp(&a.timestamp)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both pinned first, sorted by timestamp
|
||||||
|
assert!(entries[0].is_pinned);
|
||||||
|
assert_eq!(entries[0].id, "3"); // pinned newer
|
||||||
|
assert!(entries[1].is_pinned);
|
||||||
|
assert_eq!(entries[1].id, "1"); // pinned older
|
||||||
|
// Then unpinned
|
||||||
|
assert!(!entries[2].is_pinned);
|
||||||
|
assert_eq!(entries[2].id, "2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Entry filtering tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_entries_by_language() {
|
||||||
|
let history = ClipboardHistory {
|
||||||
|
entries: vec![
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "1".to_string(),
|
||||||
|
content: "rust code".to_string(),
|
||||||
|
language: Some("rust".to_string()),
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "2".to_string(),
|
||||||
|
content: "js code".to_string(),
|
||||||
|
language: Some("javascript".to_string()),
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "3".to_string(),
|
||||||
|
content: "more rust".to_string(),
|
||||||
|
language: Some("rust".to_string()),
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let filtered: Vec<_> = history
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.language.as_ref() == Some(&"rust".to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(filtered.len(), 2);
|
||||||
|
assert!(filtered.iter().all(|e| e.language == Some("rust".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_entries_by_content() {
|
||||||
|
let history = ClipboardHistory {
|
||||||
|
entries: vec![
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "1".to_string(),
|
||||||
|
content: "fn hello_world()".to_string(),
|
||||||
|
language: Some("rust".to_string()),
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "2".to_string(),
|
||||||
|
content: "function hello()".to_string(),
|
||||||
|
language: Some("javascript".to_string()),
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "3".to_string(),
|
||||||
|
content: "def goodbye()".to_string(),
|
||||||
|
language: Some("python".to_string()),
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = "hello";
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let filtered: Vec<_> = history
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.content.to_lowercase().contains(&query_lower))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(filtered.len(), 2);
|
||||||
|
assert!(filtered[0].content.contains("hello"));
|
||||||
|
assert!(filtered[1].content.contains("hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_entries_case_insensitive() {
|
||||||
|
let history = ClipboardHistory {
|
||||||
|
entries: vec![
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "1".to_string(),
|
||||||
|
content: "HELLO WORLD".to_string(),
|
||||||
|
language: None,
|
||||||
|
source: None,
|
||||||
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = "hello";
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let filtered: Vec<_> = history
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.content.to_lowercase().contains(&query_lower))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(filtered.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Unique languages extraction test ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_unique_languages() {
|
||||||
|
let history = ClipboardHistory {
|
||||||
|
entries: vec![
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "1".to_string(),
|
||||||
|
content: "".to_string(),
|
||||||
|
language: Some("rust".to_string()),
|
||||||
|
source: None,
|
||||||
|
timestamp: "".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "2".to_string(),
|
||||||
|
content: "".to_string(),
|
||||||
|
language: Some("javascript".to_string()),
|
||||||
|
source: None,
|
||||||
|
timestamp: "".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "3".to_string(),
|
||||||
|
content: "".to_string(),
|
||||||
|
language: Some("rust".to_string()), // Duplicate
|
||||||
|
source: None,
|
||||||
|
timestamp: "".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "4".to_string(),
|
||||||
|
content: "".to_string(),
|
||||||
|
language: None, // No language
|
||||||
|
source: None,
|
||||||
|
timestamp: "".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut languages: Vec<String> = history
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.language.clone())
|
||||||
|
.collect();
|
||||||
|
languages.sort();
|
||||||
|
languages.dedup();
|
||||||
|
|
||||||
|
assert_eq!(languages.len(), 2);
|
||||||
|
assert!(languages.contains(&"rust".to_string()));
|
||||||
|
assert!(languages.contains(&"javascript".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Retain pinned entries test ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retain_pinned_on_clear() {
|
||||||
|
let mut history = ClipboardHistory {
|
||||||
|
entries: vec![
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "1".to_string(),
|
||||||
|
content: "pinned".to_string(),
|
||||||
|
language: None,
|
||||||
|
source: None,
|
||||||
|
timestamp: "".to_string(),
|
||||||
|
is_pinned: true,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "2".to_string(),
|
||||||
|
content: "unpinned".to_string(),
|
||||||
|
language: None,
|
||||||
|
source: None,
|
||||||
|
timestamp: "".to_string(),
|
||||||
|
is_pinned: false,
|
||||||
|
},
|
||||||
|
ClipboardEntry {
|
||||||
|
id: "3".to_string(),
|
||||||
|
content: "another pinned".to_string(),
|
||||||
|
language: None,
|
||||||
|
source: None,
|
||||||
|
timestamp: "".to_string(),
|
||||||
|
is_pinned: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate clear (keep only pinned)
|
||||||
|
history.entries.retain(|e| e.is_pinned);
|
||||||
|
|
||||||
|
assert_eq!(history.entries.len(), 2);
|
||||||
|
assert!(history.entries.iter().all(|e| e.is_pinned));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +1,817 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
use crate::wsl_bridge::SharedBridge;
|
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
|
||||||
|
use crate::bridge_manager::SharedBridgeManager;
|
||||||
|
use crate::config::{ClaudeStartOptions, HikariConfig};
|
||||||
|
use crate::stats::UsageStats;
|
||||||
|
use crate::temp_manager::SharedTempFileManager;
|
||||||
|
|
||||||
|
const CONFIG_STORE_KEY: &str = "config";
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_claude(
|
pub async fn start_claude(
|
||||||
app: AppHandle,
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
bridge: State<'_, SharedBridge>,
|
conversation_id: String,
|
||||||
working_dir: String,
|
options: ClaudeStartOptions,
|
||||||
allowed_tools: Option<Vec<String>>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut bridge = bridge.lock();
|
let mut manager = bridge_manager.lock();
|
||||||
bridge.start(app, &working_dir, allowed_tools.unwrap_or_default())
|
manager.start_claude(&conversation_id, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> {
|
pub async fn stop_claude(
|
||||||
let mut bridge = bridge.lock();
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
bridge.stop(&app);
|
conversation_id: String,
|
||||||
Ok(())
|
) -> Result<(), String> {
|
||||||
|
let mut manager = bridge_manager.lock();
|
||||||
|
manager.stop_claude(&conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn send_prompt(bridge: State<'_, SharedBridge>, message: String) -> Result<(), String> {
|
pub async fn interrupt_claude(
|
||||||
let mut bridge = bridge.lock();
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
bridge.send_message(&message)
|
conversation_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut manager = bridge_manager.lock();
|
||||||
|
manager.interrupt_claude(&conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn is_claude_running(bridge: State<'_, SharedBridge>) -> Result<bool, String> {
|
pub async fn send_prompt(
|
||||||
let bridge = bridge.lock();
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
Ok(bridge.is_running())
|
conversation_id: String,
|
||||||
|
message: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut manager = bridge_manager.lock();
|
||||||
|
manager.send_prompt(&conversation_id, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result<String, String> {
|
pub async fn is_claude_running(
|
||||||
let bridge = bridge.lock();
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
Ok(bridge.get_working_directory().to_string())
|
conversation_id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let manager = bridge_manager.lock();
|
||||||
|
Ok(manager.is_claude_running(&conversation_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_working_directory(
|
||||||
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
|
conversation_id: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let manager = bridge_manager.lock();
|
||||||
|
manager.get_working_directory(&conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn select_wsl_directory() -> Result<String, String> {
|
pub async fn select_wsl_directory() -> Result<String, String> {
|
||||||
Ok("/home".to_string())
|
Ok("/home".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
|
||||||
|
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match store.get(CONFIG_STORE_KEY) {
|
||||||
|
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||||
|
None => Ok(HikariConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> {
|
||||||
|
let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&config).map_err(|e| e.to_string())?;
|
||||||
|
store.set(CONFIG_STORE_KEY, value);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_usage_stats(
|
||||||
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
|
conversation_id: String,
|
||||||
|
) -> Result<UsageStats, String> {
|
||||||
|
let manager = bridge_manager.lock();
|
||||||
|
manager.get_usage_stats(&conversation_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load persisted lifetime stats from store (no bridge required)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_persisted_stats(app: AppHandle) -> Result<UsageStats, String> {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
|
||||||
|
// Load persisted stats if available
|
||||||
|
if let Some(persisted) = crate::stats::load_stats(&app).await {
|
||||||
|
stats.apply_persisted(persisted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn validate_directory(
|
||||||
|
path: String,
|
||||||
|
current_dir: Option<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let path = Path::new(&path);
|
||||||
|
|
||||||
|
// Expand ~ to home directory
|
||||||
|
let expanded_path = if path.starts_with("~") {
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
let home_path = Path::new(&home);
|
||||||
|
if path == Path::new("~") {
|
||||||
|
home_path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
home_path.join(path.strip_prefix("~").unwrap())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err("Could not determine home directory".to_string());
|
||||||
|
}
|
||||||
|
} else if path.is_relative() {
|
||||||
|
// Handle relative paths (., .., or any relative path) by resolving against current_dir
|
||||||
|
if let Some(ref cwd) = current_dir {
|
||||||
|
Path::new(cwd).join(path)
|
||||||
|
} else {
|
||||||
|
path.to_path_buf()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
path.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if the path exists and is a directory
|
||||||
|
if !expanded_path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"Directory does not exist: {}",
|
||||||
|
expanded_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !expanded_path.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"Path is not a directory: {}",
|
||||||
|
expanded_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the canonicalized (absolute) path
|
||||||
|
expanded_path
|
||||||
|
.canonicalize()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.map_err(|e| format!("Failed to resolve path: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn load_saved_achievements(
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<Vec<AchievementUnlockedEvent>, String> {
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
// Load achievements from persistent store
|
||||||
|
let progress = load_achievements(&app).await;
|
||||||
|
|
||||||
|
// Create events for all previously unlocked achievements
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for achievement_id in &progress.unlocked {
|
||||||
|
let mut info = get_achievement_info(achievement_id);
|
||||||
|
info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now
|
||||||
|
events.push(AchievementUnlockedEvent { achievement: info });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn answer_question(
|
||||||
|
bridge_manager: State<'_, SharedBridgeManager>,
|
||||||
|
conversation_id: String,
|
||||||
|
tool_use_id: String,
|
||||||
|
answers: serde_json::Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut manager = bridge_manager.lock();
|
||||||
|
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_skills() -> Result<Vec<String>, String> {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
// Get the home directory
|
||||||
|
let home =
|
||||||
|
std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?;
|
||||||
|
|
||||||
|
let skills_dir = Path::new(&home).join(".claude").join("skills");
|
||||||
|
|
||||||
|
// If the skills directory doesn't exist, return empty list
|
||||||
|
if !skills_dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the directory and collect skill names
|
||||||
|
let mut skills = Vec::new();
|
||||||
|
let entries =
|
||||||
|
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
// Only include directories that contain a SKILL.md file
|
||||||
|
if path.is_dir() {
|
||||||
|
let skill_file = path.join("SKILL.md");
|
||||||
|
if skill_file.exists() {
|
||||||
|
if let Some(name) = path.file_name() {
|
||||||
|
skills.push(name.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically
|
||||||
|
skills.sort();
|
||||||
|
|
||||||
|
Ok(skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct UpdateInfo {
|
||||||
|
pub current_version: String,
|
||||||
|
pub latest_version: String,
|
||||||
|
pub has_update: bool,
|
||||||
|
pub release_url: String,
|
||||||
|
pub release_notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct GiteaRelease {
|
||||||
|
tag_name: String,
|
||||||
|
html_url: String,
|
||||||
|
body: Option<String>,
|
||||||
|
prerelease: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
||||||
|
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
const RELEASES_API: &str =
|
||||||
|
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
|
||||||
|
|
||||||
|
// Fetch releases from Gitea API
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(RELEASES_API)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API returned status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||||
|
|
||||||
|
let releases: Vec<GiteaRelease> =
|
||||||
|
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
|
||||||
|
|
||||||
|
// Find the latest non-prerelease, or fall back to latest prerelease
|
||||||
|
let latest = releases
|
||||||
|
.iter()
|
||||||
|
.find(|r| !r.prerelease)
|
||||||
|
.or_else(|| releases.first());
|
||||||
|
|
||||||
|
let latest = match latest {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return Err("No releases found".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse version strings (remove 'v' prefix if present)
|
||||||
|
let current = semver::Version::parse(CURRENT_VERSION)
|
||||||
|
.map_err(|e| format!("Failed to parse current version: {}", e))?;
|
||||||
|
|
||||||
|
let latest_tag = latest.tag_name.trim_start_matches('v');
|
||||||
|
let latest_ver = semver::Version::parse(latest_tag)
|
||||||
|
.map_err(|e| format!("Failed to parse latest version: {}", e))?;
|
||||||
|
|
||||||
|
Ok(UpdateInfo {
|
||||||
|
current_version: CURRENT_VERSION.to_string(),
|
||||||
|
latest_version: latest.tag_name.clone(),
|
||||||
|
has_update: latest_ver > current,
|
||||||
|
release_url: latest.html_url.clone(),
|
||||||
|
release_notes: latest.body.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct SavedFileInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub filename: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_temp_file(
|
||||||
|
temp_manager: State<'_, SharedTempFileManager>,
|
||||||
|
conversation_id: String,
|
||||||
|
data: Vec<u8>,
|
||||||
|
filename: Option<String>,
|
||||||
|
) -> Result<SavedFileInfo, String> {
|
||||||
|
let mut manager = temp_manager.lock();
|
||||||
|
let path = manager.save_file(&conversation_id, &data, filename.as_deref())?;
|
||||||
|
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
Ok(SavedFileInfo {
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
filename,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn register_temp_file(
|
||||||
|
temp_manager: State<'_, SharedTempFileManager>,
|
||||||
|
conversation_id: String,
|
||||||
|
file_path: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut manager = temp_manager.lock();
|
||||||
|
manager.register_file(&conversation_id, PathBuf::from(file_path));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_temp_files(
|
||||||
|
temp_manager: State<'_, SharedTempFileManager>,
|
||||||
|
conversation_id: String,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let manager = temp_manager.lock();
|
||||||
|
let files = manager.get_files_for_conversation(&conversation_id);
|
||||||
|
Ok(files.iter().map(|p| p.to_string_lossy().to_string()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn cleanup_temp_files(
|
||||||
|
temp_manager: State<'_, SharedTempFileManager>,
|
||||||
|
conversation_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut manager = temp_manager.lock();
|
||||||
|
manager.cleanup_conversation(&conversation_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn cleanup_all_temp_files(
|
||||||
|
temp_manager: State<'_, SharedTempFileManager>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut manager = temp_manager.lock();
|
||||||
|
manager.cleanup_all()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn cleanup_orphaned_temp_files(
|
||||||
|
temp_manager: State<'_, SharedTempFileManager>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let mut manager = temp_manager.lock();
|
||||||
|
manager.cleanup_orphaned_files()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
|
||||||
|
let metadata = std::fs::metadata(&file_path)
|
||||||
|
.map_err(|e| format!("Failed to get file metadata: {}", e))?;
|
||||||
|
Ok(metadata.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Editor File Operations ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
#[serde(rename = "isDirectory")]
|
||||||
|
pub is_directory: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_directory(path: String) -> Result<Vec<FileEntry>, String> {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let dir_path = Path::new(&path);
|
||||||
|
|
||||||
|
if !dir_path.exists() {
|
||||||
|
return Err(format!("Directory does not exist: {}", path));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dir_path.is_dir() {
|
||||||
|
return Err(format!("Path is not a directory: {}", path));
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = fs::read_dir(dir_path)
|
||||||
|
.map_err(|e| format!("Failed to read directory: {}", e))?;
|
||||||
|
|
||||||
|
let mut file_entries = Vec::new();
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Skip hidden files by default (can be made configurable later)
|
||||||
|
if name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
file_entries.push(FileEntry {
|
||||||
|
name,
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
is_directory: path.is_dir(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(file_entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_file_content(path: String) -> Result<String, String> {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("Failed to read file: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_file_content(path: String, content: String) -> Result<(), String> {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fs::write(&path, content)
|
||||||
|
.map_err(|e| format!("Failed to write file: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_file(path: String) -> Result<(), String> {
|
||||||
|
use std::fs::File;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let file_path = Path::new(&path);
|
||||||
|
|
||||||
|
if file_path.exists() {
|
||||||
|
return Err("File already exists".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
File::create(file_path).map_err(|e| format!("Failed to create file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_directory(path: String) -> Result<(), String> {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let dir_path = Path::new(&path);
|
||||||
|
|
||||||
|
if dir_path.exists() {
|
||||||
|
return Err("Directory already exists".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(dir_path).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_file(path: String) -> Result<(), String> {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let file_path = Path::new(&path);
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Err("File does not exist".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_path.is_dir() {
|
||||||
|
return Err("Path is a directory, use delete_directory instead".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_file(file_path).map_err(|e| format!("Failed to delete file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_directory(path: String) -> Result<(), String> {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let dir_path = Path::new(&path);
|
||||||
|
|
||||||
|
if !dir_path.exists() {
|
||||||
|
return Err("Directory does not exist".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dir_path.is_dir() {
|
||||||
|
return Err("Path is not a directory".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(dir_path).map_err(|e| format!("Failed to delete directory: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn rename_path(old_path: String, new_path: String) -> Result<(), String> {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let old = Path::new(&old_path);
|
||||||
|
let new = Path::new(&new_path);
|
||||||
|
|
||||||
|
if !old.exists() {
|
||||||
|
return Err("Path does not exist".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if new.exists() {
|
||||||
|
return Err("Destination already exists".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(old, new).map_err(|e| format!("Failed to rename: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// Helper to run async tests
|
||||||
|
fn run_async<F: std::future::Future>(f: F) -> F::Output {
|
||||||
|
tokio::runtime::Runtime::new().unwrap().block_on(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== validate_directory tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_directory_absolute_path_exists() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let path = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let result = run_async(validate_directory(path.clone(), None));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
// Canonicalized path should be returned
|
||||||
|
assert!(result.unwrap().contains(&temp_dir.path().file_name().unwrap().to_string_lossy().to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_directory_path_not_exists() {
|
||||||
|
let result = run_async(validate_directory(
|
||||||
|
"/nonexistent/path/that/does/not/exist".to_string(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("does not exist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_directory_path_is_file() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("test_file.txt");
|
||||||
|
File::create(&file_path).unwrap();
|
||||||
|
|
||||||
|
let result = run_async(validate_directory(
|
||||||
|
file_path.to_string_lossy().to_string(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("not a directory"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_directory_home_expansion() {
|
||||||
|
// This test assumes HOME is set (which it should be on most systems)
|
||||||
|
if std::env::var_os("HOME").is_some() {
|
||||||
|
let result = run_async(validate_directory("~".to_string(), None));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
// Should not contain ~ after expansion
|
||||||
|
assert!(!result.unwrap().contains("~"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_directory_home_subpath_expansion() {
|
||||||
|
// This test assumes HOME is set and has some subdirectory
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
let home_path = std::path::Path::new(&home);
|
||||||
|
// Find any subdirectory in home
|
||||||
|
if let Ok(entries) = fs::read_dir(home_path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if entry.path().is_dir() {
|
||||||
|
let subdir_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let tilde_path = format!("~/{}", subdir_name);
|
||||||
|
let result = run_async(validate_directory(tilde_path, None));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(!result.unwrap().contains("~"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_directory_relative_path_with_current_dir() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let subdir = temp_dir.path().join("subdir");
|
||||||
|
fs::create_dir(&subdir).unwrap();
|
||||||
|
|
||||||
|
let result = run_async(validate_directory(
|
||||||
|
"subdir".to_string(),
|
||||||
|
Some(temp_dir.path().to_string_lossy().to_string()),
|
||||||
|
));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(result.unwrap().contains("subdir"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_directory_dot_path() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let result = run_async(validate_directory(
|
||||||
|
".".to_string(),
|
||||||
|
Some(temp_dir.path().to_string_lossy().to_string()),
|
||||||
|
));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_directory_dotdot_path() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let subdir = temp_dir.path().join("subdir");
|
||||||
|
fs::create_dir(&subdir).unwrap();
|
||||||
|
|
||||||
|
let result = run_async(validate_directory(
|
||||||
|
"..".to_string(),
|
||||||
|
Some(subdir.to_string_lossy().to_string()),
|
||||||
|
));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
// Should resolve to parent
|
||||||
|
let resolved = result.unwrap();
|
||||||
|
assert!(resolved.contains(&temp_dir.path().file_name().unwrap().to_string_lossy().to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_directory_relative_without_current_dir() {
|
||||||
|
// Relative path without current_dir - should fail since relative path likely won't exist
|
||||||
|
let result = run_async(validate_directory(
|
||||||
|
"some_random_nonexistent_relative_path".to_string(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== get_file_size tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_file_size_empty_file() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("empty.txt");
|
||||||
|
File::create(&file_path).unwrap();
|
||||||
|
|
||||||
|
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_file_size_with_content() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("content.txt");
|
||||||
|
let mut file = File::create(&file_path).unwrap();
|
||||||
|
file.write_all(b"Hello, Hikari!").unwrap();
|
||||||
|
|
||||||
|
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), 14); // "Hello, Hikari!" is 14 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_file_size_larger_file() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("large.txt");
|
||||||
|
let mut file = File::create(&file_path).unwrap();
|
||||||
|
// Write 1000 bytes
|
||||||
|
let data = vec![b'x'; 1000];
|
||||||
|
file.write_all(&data).unwrap();
|
||||||
|
|
||||||
|
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_file_size_nonexistent_file() {
|
||||||
|
let result = run_async(get_file_size(
|
||||||
|
"/nonexistent/path/file.txt".to_string(),
|
||||||
|
));
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Failed to get file metadata"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_file_size_directory() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Getting "size" of a directory should work but return directory metadata
|
||||||
|
// This is actually valid - directories have metadata too
|
||||||
|
let result = run_async(get_file_size(temp_dir.path().to_string_lossy().to_string()));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
// Directory size is platform-dependent, just check it returns something
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== list_skills tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_skills_no_skills_dir() {
|
||||||
|
// This test is tricky because it depends on HOME being set
|
||||||
|
// and potentially affecting real user data, so we'll just
|
||||||
|
// verify the function doesn't panic
|
||||||
|
let result = run_async(list_skills());
|
||||||
|
// Should either return Ok with a list or Ok with empty vec
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== select_wsl_directory tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_select_wsl_directory_returns_home() {
|
||||||
|
let result = run_async(select_wsl_directory());
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), "/home");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UpdateInfo struct tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_info_serialization() {
|
||||||
|
let info = UpdateInfo {
|
||||||
|
current_version: "1.0.0".to_string(),
|
||||||
|
latest_version: "0.4.0".to_string(),
|
||||||
|
has_update: true,
|
||||||
|
release_url: "https://example.com/release".to_string(),
|
||||||
|
release_notes: Some("New features!".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&info).unwrap();
|
||||||
|
assert!(json.contains("1.0.0"));
|
||||||
|
assert!(json.contains("0.4.0"));
|
||||||
|
assert!(json.contains("true"));
|
||||||
|
assert!(json.contains("New features!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_info_without_notes() {
|
||||||
|
let info = UpdateInfo {
|
||||||
|
current_version: "1.0.0".to_string(),
|
||||||
|
latest_version: "1.0.0".to_string(),
|
||||||
|
has_update: false,
|
||||||
|
release_url: "https://example.com/release".to_string(),
|
||||||
|
release_notes: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&info).unwrap();
|
||||||
|
assert!(json.contains("null") || json.contains("release_notes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SavedFileInfo struct tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_saved_file_info_serialization() {
|
||||||
|
let info = SavedFileInfo {
|
||||||
|
path: "/tmp/test.txt".to_string(),
|
||||||
|
filename: "test.txt".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&info).unwrap();
|
||||||
|
assert!(json.contains("/tmp/test.txt"));
|
||||||
|
assert!(json.contains("test.txt"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
use crate::providers::ProviderType;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ClaudeStartOptions {
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider_type: ProviderType,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub working_dir: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_instructions: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp_servers_json: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub allowed_tools: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub skip_greeting: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub resume_session_id: Option<String>,
|
||||||
|
|
||||||
|
// Ollama-specific options
|
||||||
|
#[serde(default = "default_ollama_base_url")]
|
||||||
|
pub ollama_base_url: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub ollama_model: Option<String>,
|
||||||
|
|
||||||
|
// OpenAI-specific options
|
||||||
|
#[serde(default)]
|
||||||
|
pub openai_api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_openai_base_url")]
|
||||||
|
pub openai_base_url: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub openai_model: Option<String>,
|
||||||
|
|
||||||
|
// Anthropic-specific options
|
||||||
|
#[serde(default)]
|
||||||
|
pub anthropic_api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_anthropic_base_url")]
|
||||||
|
pub anthropic_base_url: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub anthropic_model: Option<String>,
|
||||||
|
|
||||||
|
// Gemini-specific options
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini_api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini_model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HikariConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider_type: ProviderType,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_instructions: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp_servers_json: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_granted_tools: Vec<String>,
|
||||||
|
|
||||||
|
// Ollama-specific settings
|
||||||
|
#[serde(default = "default_ollama_base_url")]
|
||||||
|
pub ollama_base_url: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub ollama_model: Option<String>,
|
||||||
|
|
||||||
|
// OpenAI-specific settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub openai_api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_openai_base_url")]
|
||||||
|
pub openai_base_url: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub openai_model: Option<String>,
|
||||||
|
|
||||||
|
// Anthropic-specific settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub anthropic_api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_anthropic_base_url")]
|
||||||
|
pub anthropic_base_url: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub anthropic_model: Option<String>,
|
||||||
|
|
||||||
|
// Gemini-specific settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini_api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini_model: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub theme: Theme,
|
||||||
|
|
||||||
|
#[serde(default = "default_greeting_enabled")]
|
||||||
|
pub greeting_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub greeting_custom_prompt: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_notifications_enabled")]
|
||||||
|
pub notifications_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_notification_volume")]
|
||||||
|
pub notification_volume: f32,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub always_on_top: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_update_checks_enabled")]
|
||||||
|
pub update_checks_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub character_panel_width: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(default = "default_font_size")]
|
||||||
|
pub font_size: u32,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub minimize_to_tray: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub streamer_mode: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub streamer_hide_paths: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub compact_mode: bool,
|
||||||
|
|
||||||
|
// Profile fields
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_name: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_avatar_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_bio: Option<String>,
|
||||||
|
|
||||||
|
// Custom theme colors
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_theme_colors: CustomThemeColors,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HikariConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
provider_type: ProviderType::default(),
|
||||||
|
model: None,
|
||||||
|
api_key: None,
|
||||||
|
custom_instructions: None,
|
||||||
|
mcp_servers_json: None,
|
||||||
|
auto_granted_tools: Vec::new(),
|
||||||
|
ollama_base_url: default_ollama_base_url(),
|
||||||
|
ollama_model: None,
|
||||||
|
openai_api_key: None,
|
||||||
|
openai_base_url: default_openai_base_url(),
|
||||||
|
openai_model: None,
|
||||||
|
anthropic_api_key: None,
|
||||||
|
anthropic_base_url: default_anthropic_base_url(),
|
||||||
|
anthropic_model: None,
|
||||||
|
gemini_api_key: None,
|
||||||
|
gemini_model: None,
|
||||||
|
theme: Theme::default(),
|
||||||
|
greeting_enabled: true,
|
||||||
|
greeting_custom_prompt: None,
|
||||||
|
notifications_enabled: true,
|
||||||
|
notification_volume: 0.7,
|
||||||
|
always_on_top: false,
|
||||||
|
update_checks_enabled: true,
|
||||||
|
character_panel_width: None,
|
||||||
|
font_size: 14,
|
||||||
|
minimize_to_tray: false,
|
||||||
|
streamer_mode: false,
|
||||||
|
streamer_hide_paths: false,
|
||||||
|
compact_mode: false,
|
||||||
|
profile_name: None,
|
||||||
|
profile_avatar_path: None,
|
||||||
|
profile_bio: None,
|
||||||
|
custom_theme_colors: CustomThemeColors::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_update_checks_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_greeting_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_notifications_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_notification_volume() -> f32 {
|
||||||
|
0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_font_size() -> u32 {
|
||||||
|
14
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ollama_base_url() -> String {
|
||||||
|
"http://localhost:11434".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_openai_base_url() -> String {
|
||||||
|
"https://api.openai.com/v1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_anthropic_base_url() -> String {
|
||||||
|
"https://api.anthropic.com".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Theme {
|
||||||
|
#[default]
|
||||||
|
Dark,
|
||||||
|
Light,
|
||||||
|
#[serde(rename = "high-contrast")]
|
||||||
|
HighContrast,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct CustomThemeColors {
|
||||||
|
#[serde(default)]
|
||||||
|
pub bg_primary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bg_secondary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bg_terminal: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accent_primary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accent_secondary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub text_primary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub text_secondary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub border_color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = HikariConfig::default();
|
||||||
|
assert_eq!(config.provider_type, ProviderType::ClaudeCli);
|
||||||
|
assert!(config.model.is_none());
|
||||||
|
assert!(config.api_key.is_none());
|
||||||
|
assert!(config.custom_instructions.is_none());
|
||||||
|
assert!(config.mcp_servers_json.is_none());
|
||||||
|
assert!(config.auto_granted_tools.is_empty());
|
||||||
|
assert_eq!(config.ollama_base_url, "http://localhost:11434");
|
||||||
|
assert!(config.ollama_model.is_none());
|
||||||
|
// OpenAI defaults
|
||||||
|
assert!(config.openai_api_key.is_none());
|
||||||
|
assert_eq!(config.openai_base_url, "https://api.openai.com/v1");
|
||||||
|
assert!(config.openai_model.is_none());
|
||||||
|
// Anthropic defaults
|
||||||
|
assert!(config.anthropic_api_key.is_none());
|
||||||
|
assert_eq!(config.anthropic_base_url, "https://api.anthropic.com");
|
||||||
|
assert!(config.anthropic_model.is_none());
|
||||||
|
// Gemini defaults
|
||||||
|
assert!(config.gemini_api_key.is_none());
|
||||||
|
assert!(config.gemini_model.is_none());
|
||||||
|
// Other settings
|
||||||
|
assert_eq!(config.theme, Theme::Dark);
|
||||||
|
assert!(config.greeting_enabled);
|
||||||
|
assert!(config.greeting_custom_prompt.is_none());
|
||||||
|
assert!(!config.always_on_top);
|
||||||
|
assert!(config.update_checks_enabled);
|
||||||
|
assert!(config.character_panel_width.is_none());
|
||||||
|
assert_eq!(config.font_size, 14);
|
||||||
|
assert!(!config.minimize_to_tray);
|
||||||
|
assert!(!config.streamer_mode);
|
||||||
|
assert!(!config.streamer_hide_paths);
|
||||||
|
assert!(!config.compact_mode);
|
||||||
|
assert!(config.profile_name.is_none());
|
||||||
|
assert!(config.profile_avatar_path.is_none());
|
||||||
|
assert!(config.profile_bio.is_none());
|
||||||
|
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serialization() {
|
||||||
|
let config = HikariConfig {
|
||||||
|
provider_type: ProviderType::ClaudeCli,
|
||||||
|
model: Some("claude-sonnet-4-20250514".to_string()),
|
||||||
|
api_key: None,
|
||||||
|
custom_instructions: Some("Be helpful".to_string()),
|
||||||
|
mcp_servers_json: None,
|
||||||
|
auto_granted_tools: vec!["Read".to_string(), "Glob".to_string()],
|
||||||
|
ollama_base_url: "http://localhost:11434".to_string(),
|
||||||
|
ollama_model: None,
|
||||||
|
openai_api_key: None,
|
||||||
|
openai_base_url: "https://api.openai.com/v1".to_string(),
|
||||||
|
openai_model: None,
|
||||||
|
anthropic_api_key: None,
|
||||||
|
anthropic_base_url: "https://api.anthropic.com".to_string(),
|
||||||
|
anthropic_model: None,
|
||||||
|
gemini_api_key: None,
|
||||||
|
gemini_model: None,
|
||||||
|
theme: Theme::Light,
|
||||||
|
greeting_enabled: true,
|
||||||
|
greeting_custom_prompt: Some("Hello!".to_string()),
|
||||||
|
notifications_enabled: true,
|
||||||
|
notification_volume: 0.7,
|
||||||
|
always_on_top: true,
|
||||||
|
update_checks_enabled: true,
|
||||||
|
character_panel_width: Some(400),
|
||||||
|
font_size: 16,
|
||||||
|
minimize_to_tray: true,
|
||||||
|
streamer_mode: false,
|
||||||
|
streamer_hide_paths: false,
|
||||||
|
compact_mode: false,
|
||||||
|
profile_name: Some("Test User".to_string()),
|
||||||
|
profile_avatar_path: None,
|
||||||
|
profile_bio: Some("A test bio".to_string()),
|
||||||
|
custom_theme_colors: CustomThemeColors::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let deserialized: HikariConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(deserialized.model, config.model);
|
||||||
|
assert_eq!(deserialized.custom_instructions, config.custom_instructions);
|
||||||
|
assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools);
|
||||||
|
assert_eq!(deserialized.theme, Theme::Light);
|
||||||
|
assert!(deserialized.greeting_enabled);
|
||||||
|
assert_eq!(
|
||||||
|
deserialized.greeting_custom_prompt,
|
||||||
|
Some("Hello!".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_theme_serialization() {
|
||||||
|
let dark = Theme::Dark;
|
||||||
|
let light = Theme::Light;
|
||||||
|
let high_contrast = Theme::HighContrast;
|
||||||
|
|
||||||
|
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
||||||
|
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&high_contrast).unwrap(),
|
||||||
|
"\"high-contrast\""
|
||||||
|
);
|
||||||
|
|
||||||
|
let custom = Theme::Custom;
|
||||||
|
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GitStatus {
|
||||||
|
pub is_repo: bool,
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub upstream: Option<String>,
|
||||||
|
pub ahead: u32,
|
||||||
|
pub behind: u32,
|
||||||
|
pub staged: Vec<GitFileChange>,
|
||||||
|
pub unstaged: Vec<GitFileChange>,
|
||||||
|
pub untracked: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GitFileChange {
|
||||||
|
pub path: String,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GitBranch {
|
||||||
|
pub name: String,
|
||||||
|
pub is_current: bool,
|
||||||
|
pub is_remote: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GitLogEntry {
|
||||||
|
pub hash: String,
|
||||||
|
pub short_hash: String,
|
||||||
|
pub author: String,
|
||||||
|
pub date: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(args)
|
||||||
|
.current_dir(working_dir)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute git: {}", e))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||||
|
} else {
|
||||||
|
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_status(working_dir: String) -> Result<GitStatus, String> {
|
||||||
|
// Check if it's a git repo
|
||||||
|
let is_repo = run_git_command(&working_dir, &["rev-parse", "--git-dir"]).is_ok();
|
||||||
|
|
||||||
|
if !is_repo {
|
||||||
|
return Ok(GitStatus {
|
||||||
|
is_repo: false,
|
||||||
|
branch: None,
|
||||||
|
upstream: None,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
staged: vec![],
|
||||||
|
unstaged: vec![],
|
||||||
|
untracked: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current branch
|
||||||
|
let branch = run_git_command(&working_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string());
|
||||||
|
|
||||||
|
// Get upstream branch
|
||||||
|
let upstream = run_git_command(
|
||||||
|
&working_dir,
|
||||||
|
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string());
|
||||||
|
|
||||||
|
// Get ahead/behind counts
|
||||||
|
let (ahead, behind) = if upstream.is_some() {
|
||||||
|
let rev_list =
|
||||||
|
run_git_command(&working_dir, &["rev-list", "--left-right", "--count", "@{u}...HEAD"])
|
||||||
|
.unwrap_or_default();
|
||||||
|
let parts: Vec<&str> = rev_list.trim().split('\t').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
(
|
||||||
|
parts[1].parse().unwrap_or(0),
|
||||||
|
parts[0].parse().unwrap_or(0),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get status with porcelain format
|
||||||
|
let status_output =
|
||||||
|
run_git_command(&working_dir, &["status", "--porcelain=v1"]).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut staged = vec![];
|
||||||
|
let mut unstaged = vec![];
|
||||||
|
let mut untracked = vec![];
|
||||||
|
|
||||||
|
for line in status_output.lines() {
|
||||||
|
if line.len() < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index_status = line.chars().next().unwrap_or(' ');
|
||||||
|
let worktree_status = line.chars().nth(1).unwrap_or(' ');
|
||||||
|
let path = line[3..].to_string();
|
||||||
|
|
||||||
|
// Untracked files
|
||||||
|
if index_status == '?' && worktree_status == '?' {
|
||||||
|
untracked.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staged changes (index status)
|
||||||
|
if index_status != ' ' && index_status != '?' {
|
||||||
|
staged.push(GitFileChange {
|
||||||
|
path: path.clone(),
|
||||||
|
status: match index_status {
|
||||||
|
'M' => "modified".to_string(),
|
||||||
|
'A' => "added".to_string(),
|
||||||
|
'D' => "deleted".to_string(),
|
||||||
|
'R' => "renamed".to_string(),
|
||||||
|
'C' => "copied".to_string(),
|
||||||
|
_ => "unknown".to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unstaged changes (worktree status)
|
||||||
|
if worktree_status != ' ' && worktree_status != '?' {
|
||||||
|
unstaged.push(GitFileChange {
|
||||||
|
path,
|
||||||
|
status: match worktree_status {
|
||||||
|
'M' => "modified".to_string(),
|
||||||
|
'D' => "deleted".to_string(),
|
||||||
|
_ => "unknown".to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(GitStatus {
|
||||||
|
is_repo: true,
|
||||||
|
branch,
|
||||||
|
upstream,
|
||||||
|
ahead,
|
||||||
|
behind,
|
||||||
|
staged,
|
||||||
|
unstaged,
|
||||||
|
untracked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_diff(working_dir: String, file_path: Option<String>, staged: bool) -> Result<String, String> {
|
||||||
|
let mut args = vec!["diff"];
|
||||||
|
|
||||||
|
if staged {
|
||||||
|
args.push("--cached");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref path) = file_path {
|
||||||
|
args.push("--");
|
||||||
|
args.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
run_git_command(&working_dir, &args)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_branches(working_dir: String) -> Result<Vec<GitBranch>, String> {
|
||||||
|
let output = run_git_command(&working_dir, &["branch", "-a", "--format=%(refname:short)\t%(HEAD)"])?;
|
||||||
|
|
||||||
|
let branches: Vec<GitBranch> = output
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let parts: Vec<&str> = line.split('\t').collect();
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = parts[0].to_string();
|
||||||
|
let is_current = parts.get(1).map(|s| *s == "*").unwrap_or(false);
|
||||||
|
let is_remote = name.starts_with("remotes/") || name.starts_with("origin/");
|
||||||
|
|
||||||
|
Some(GitBranch {
|
||||||
|
name,
|
||||||
|
is_current,
|
||||||
|
is_remote,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(branches)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_checkout(working_dir: String, branch: String) -> Result<String, String> {
|
||||||
|
run_git_command(&working_dir, &["checkout", &branch])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_stage(working_dir: String, file_path: String) -> Result<String, String> {
|
||||||
|
run_git_command(&working_dir, &["add", &file_path])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_unstage(working_dir: String, file_path: String) -> Result<String, String> {
|
||||||
|
run_git_command(&working_dir, &["restore", "--staged", &file_path])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_stage_all(working_dir: String) -> Result<String, String> {
|
||||||
|
run_git_command(&working_dir, &["add", "-A"])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_commit(working_dir: String, message: String) -> Result<String, String> {
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", &message])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_push(working_dir: String) -> Result<String, String> {
|
||||||
|
run_git_command(&working_dir, &["push"])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_pull(working_dir: String) -> Result<String, String> {
|
||||||
|
run_git_command(&working_dir, &["pull"])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_fetch(working_dir: String) -> Result<String, String> {
|
||||||
|
run_git_command(&working_dir, &["fetch", "--all"])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_log(working_dir: String, limit: Option<u32>) -> Result<Vec<GitLogEntry>, String> {
|
||||||
|
let limit_str = limit.unwrap_or(10).to_string();
|
||||||
|
let output = run_git_command(
|
||||||
|
&working_dir,
|
||||||
|
&[
|
||||||
|
"log",
|
||||||
|
&format!("-{}", limit_str),
|
||||||
|
"--pretty=format:%H\t%h\t%an\t%ar\t%s",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let entries: Vec<GitLogEntry> = output
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let parts: Vec<&str> = line.split('\t').collect();
|
||||||
|
if parts.len() < 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(GitLogEntry {
|
||||||
|
hash: parts[0].to_string(),
|
||||||
|
short_hash: parts[1].to_string(),
|
||||||
|
author: parts[2].to_string(),
|
||||||
|
date: parts[3].to_string(),
|
||||||
|
message: parts[4..].join("\t"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_discard(working_dir: String, file_path: String) -> Result<String, String> {
|
||||||
|
run_git_command(&working_dir, &["checkout", "--", &file_path])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn git_create_branch(working_dir: String, branch_name: String) -> Result<String, String> {
|
||||||
|
run_git_command(&working_dir, &["checkout", "-b", &branch_name])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// Helper to create a git repository in a temp directory
|
||||||
|
fn create_test_repo() -> TempDir {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Initialize git repo
|
||||||
|
run_git_command(&working_dir, &["init"]).unwrap();
|
||||||
|
|
||||||
|
// Configure git user for commits
|
||||||
|
run_git_command(&working_dir, &["config", "user.email", "test@example.com"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["config", "user.name", "Test User"]).unwrap();
|
||||||
|
|
||||||
|
// Disable GPG signing for tests (user may have it enabled globally)
|
||||||
|
run_git_command(&working_dir, &["config", "commit.gpgsign", "false"]).unwrap();
|
||||||
|
|
||||||
|
temp_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a file in the test repo
|
||||||
|
fn create_file(dir: &TempDir, name: &str, content: &str) {
|
||||||
|
let file_path = dir.path().join(name);
|
||||||
|
let mut file = File::create(file_path).unwrap();
|
||||||
|
file.write_all(content.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GitStatus struct tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_status_serialization() {
|
||||||
|
let status = GitStatus {
|
||||||
|
is_repo: true,
|
||||||
|
branch: Some("main".to_string()),
|
||||||
|
upstream: Some("origin/main".to_string()),
|
||||||
|
ahead: 2,
|
||||||
|
behind: 1,
|
||||||
|
staged: vec![GitFileChange {
|
||||||
|
path: "file.txt".to_string(),
|
||||||
|
status: "modified".to_string(),
|
||||||
|
}],
|
||||||
|
unstaged: vec![],
|
||||||
|
untracked: vec!["new_file.txt".to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&status).unwrap();
|
||||||
|
assert!(json.contains("\"is_repo\":true"));
|
||||||
|
assert!(json.contains("\"branch\":\"main\""));
|
||||||
|
assert!(json.contains("\"ahead\":2"));
|
||||||
|
assert!(json.contains("\"behind\":1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_status_not_a_repo() {
|
||||||
|
let status = GitStatus {
|
||||||
|
is_repo: false,
|
||||||
|
branch: None,
|
||||||
|
upstream: None,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
staged: vec![],
|
||||||
|
unstaged: vec![],
|
||||||
|
untracked: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&status).unwrap();
|
||||||
|
let deserialized: GitStatus = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(!deserialized.is_repo);
|
||||||
|
assert!(deserialized.branch.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GitFileChange struct tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_file_change_serialization() {
|
||||||
|
let change = GitFileChange {
|
||||||
|
path: "src/main.rs".to_string(),
|
||||||
|
status: "added".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&change).unwrap();
|
||||||
|
assert!(json.contains("src/main.rs"));
|
||||||
|
assert!(json.contains("added"));
|
||||||
|
|
||||||
|
let deserialized: GitFileChange = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.path, "src/main.rs");
|
||||||
|
assert_eq!(deserialized.status, "added");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GitBranch struct tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_branch_serialization() {
|
||||||
|
let branch = GitBranch {
|
||||||
|
name: "feature/new-feature".to_string(),
|
||||||
|
is_current: true,
|
||||||
|
is_remote: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&branch).unwrap();
|
||||||
|
assert!(json.contains("feature/new-feature"));
|
||||||
|
assert!(json.contains("\"is_current\":true"));
|
||||||
|
assert!(json.contains("\"is_remote\":false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_branch_remote() {
|
||||||
|
let branch = GitBranch {
|
||||||
|
name: "origin/main".to_string(),
|
||||||
|
is_current: false,
|
||||||
|
is_remote: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&branch).unwrap();
|
||||||
|
let deserialized: GitBranch = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(deserialized.is_remote);
|
||||||
|
assert!(!deserialized.is_current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GitLogEntry struct tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_log_entry_serialization() {
|
||||||
|
let entry = GitLogEntry {
|
||||||
|
hash: "abc123def456".to_string(),
|
||||||
|
short_hash: "abc123d".to_string(),
|
||||||
|
author: "Hikari".to_string(),
|
||||||
|
date: "2 hours ago".to_string(),
|
||||||
|
message: "feat: add new feature".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&entry).unwrap();
|
||||||
|
assert!(json.contains("abc123def456"));
|
||||||
|
assert!(json.contains("Hikari"));
|
||||||
|
assert!(json.contains("feat: add new feature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== git_status integration tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_status_not_a_git_repo() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let result = git_status(working_dir);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let status = result.unwrap();
|
||||||
|
assert!(!status.is_repo);
|
||||||
|
assert!(status.branch.is_none());
|
||||||
|
assert!(status.staged.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_status_empty_repo() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let result = git_status(working_dir);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let status = result.unwrap();
|
||||||
|
assert!(status.is_repo);
|
||||||
|
assert!(status.staged.is_empty());
|
||||||
|
assert!(status.unstaged.is_empty());
|
||||||
|
assert!(status.untracked.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_status_with_untracked_file() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Create an untracked file
|
||||||
|
create_file(&temp_dir, "untracked.txt", "hello");
|
||||||
|
|
||||||
|
let result = git_status(working_dir);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let status = result.unwrap();
|
||||||
|
assert!(status.is_repo);
|
||||||
|
assert!(status.untracked.contains(&"untracked.txt".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_status_with_staged_file() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Create and stage a file
|
||||||
|
create_file(&temp_dir, "staged.txt", "hello");
|
||||||
|
run_git_command(&working_dir, &["add", "staged.txt"]).unwrap();
|
||||||
|
|
||||||
|
let result = git_status(working_dir);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let status = result.unwrap();
|
||||||
|
assert!(status.is_repo);
|
||||||
|
assert!(!status.staged.is_empty());
|
||||||
|
assert_eq!(status.staged[0].path, "staged.txt");
|
||||||
|
assert_eq!(status.staged[0].status, "added");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_status_with_modified_file() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Create, stage, and commit a file
|
||||||
|
create_file(&temp_dir, "file.txt", "initial content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial commit"]).unwrap();
|
||||||
|
|
||||||
|
// Modify the file
|
||||||
|
create_file(&temp_dir, "file.txt", "modified content");
|
||||||
|
|
||||||
|
let result = git_status(working_dir);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let status = result.unwrap();
|
||||||
|
assert!(status.is_repo);
|
||||||
|
assert!(!status.unstaged.is_empty());
|
||||||
|
assert_eq!(status.unstaged[0].path, "file.txt");
|
||||||
|
assert_eq!(status.unstaged[0].status, "modified");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== git_diff integration tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_diff_no_changes() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let result = git_diff(working_dir, None, false);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(result.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_diff_with_changes() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Create and commit a file
|
||||||
|
create_file(&temp_dir, "file.txt", "initial content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||||
|
|
||||||
|
// Modify the file
|
||||||
|
create_file(&temp_dir, "file.txt", "modified content");
|
||||||
|
|
||||||
|
let result = git_diff(working_dir, None, false);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let diff = result.unwrap();
|
||||||
|
assert!(diff.contains("diff"));
|
||||||
|
assert!(diff.contains("file.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_diff_staged() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Create and commit a file
|
||||||
|
create_file(&temp_dir, "file.txt", "initial content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||||
|
|
||||||
|
// Modify and stage the file
|
||||||
|
create_file(&temp_dir, "file.txt", "modified content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
|
||||||
|
let result = git_diff(working_dir, None, true);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let diff = result.unwrap();
|
||||||
|
assert!(diff.contains("diff"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_diff_specific_file() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Create and commit files
|
||||||
|
create_file(&temp_dir, "file1.txt", "content1");
|
||||||
|
create_file(&temp_dir, "file2.txt", "content2");
|
||||||
|
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||||
|
|
||||||
|
// Modify both files
|
||||||
|
create_file(&temp_dir, "file1.txt", "modified1");
|
||||||
|
create_file(&temp_dir, "file2.txt", "modified2");
|
||||||
|
|
||||||
|
// Get diff for only file1.txt
|
||||||
|
let result = git_diff(working_dir, Some("file1.txt".to_string()), false);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let diff = result.unwrap();
|
||||||
|
assert!(diff.contains("file1.txt"));
|
||||||
|
assert!(!diff.contains("file2.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== git_branches integration tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_branches_single_branch() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Need at least one commit for branches to show
|
||||||
|
create_file(&temp_dir, "file.txt", "content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||||
|
|
||||||
|
let result = git_branches(working_dir);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let branches = result.unwrap();
|
||||||
|
assert!(!branches.is_empty());
|
||||||
|
// Should have at least one branch (main or master)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_branches_multiple_branches() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Initial commit
|
||||||
|
create_file(&temp_dir, "file.txt", "content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||||
|
|
||||||
|
// Create additional branch
|
||||||
|
run_git_command(&working_dir, &["branch", "feature-branch"]).unwrap();
|
||||||
|
|
||||||
|
let result = git_branches(working_dir);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let branches = result.unwrap();
|
||||||
|
assert!(branches.len() >= 2);
|
||||||
|
assert!(branches.iter().any(|b| b.name == "feature-branch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== git_stage and git_unstage tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_stage_file() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
create_file(&temp_dir, "file.txt", "content");
|
||||||
|
|
||||||
|
let result = git_stage(working_dir.clone(), "file.txt".to_string());
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Verify file is staged
|
||||||
|
let status = git_status(working_dir).unwrap();
|
||||||
|
assert!(status.staged.iter().any(|f| f.path == "file.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_unstage_file() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// First, commit a file so we have a HEAD to restore from
|
||||||
|
create_file(&temp_dir, "file.txt", "initial content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||||
|
|
||||||
|
// Modify and stage the file
|
||||||
|
create_file(&temp_dir, "file.txt", "modified content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
|
||||||
|
let result = git_unstage(working_dir.clone(), "file.txt".to_string());
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Verify file is unstaged (should now be in unstaged/modified, not staged)
|
||||||
|
let status = git_status(working_dir).unwrap();
|
||||||
|
assert!(!status.staged.iter().any(|f| f.path == "file.txt"));
|
||||||
|
assert!(status.unstaged.iter().any(|f| f.path == "file.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_stage_all() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
create_file(&temp_dir, "file1.txt", "content1");
|
||||||
|
create_file(&temp_dir, "file2.txt", "content2");
|
||||||
|
|
||||||
|
let result = git_stage_all(working_dir.clone());
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Verify all files are staged
|
||||||
|
let status = git_status(working_dir).unwrap();
|
||||||
|
assert_eq!(status.staged.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== git_commit tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_commit() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
create_file(&temp_dir, "file.txt", "content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
|
||||||
|
let result = git_commit(working_dir.clone(), "test commit message".to_string());
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Verify commit was made
|
||||||
|
let log = git_log(working_dir, Some(1)).unwrap();
|
||||||
|
assert!(!log.is_empty());
|
||||||
|
assert!(log[0].message.contains("test commit message"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_commit_nothing_to_commit() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Need initial commit first
|
||||||
|
create_file(&temp_dir, "file.txt", "content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||||
|
|
||||||
|
// Try to commit with nothing staged
|
||||||
|
let result = git_commit(working_dir, "empty commit".to_string());
|
||||||
|
assert!(result.is_err()); // Should fail because nothing to commit
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== git_log tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_log_empty_repo() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let result = git_log(working_dir, Some(10));
|
||||||
|
// May fail on empty repo or return empty
|
||||||
|
if let Ok(commits) = result {
|
||||||
|
assert!(commits.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_log_with_commits() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Make multiple commits
|
||||||
|
for i in 1..=3 {
|
||||||
|
create_file(&temp_dir, &format!("file{}.txt", i), "content");
|
||||||
|
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = git_log(working_dir, Some(10));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let log = result.unwrap();
|
||||||
|
assert_eq!(log.len(), 3);
|
||||||
|
assert!(log[0].message.contains("commit 3")); // Most recent first
|
||||||
|
assert!(log[2].message.contains("commit 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_log_limit() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Make 5 commits
|
||||||
|
for i in 1..=5 {
|
||||||
|
create_file(&temp_dir, &format!("file{}.txt", i), "content");
|
||||||
|
run_git_command(&working_dir, &["add", "-A"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only get last 2
|
||||||
|
let result = git_log(working_dir, Some(2));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let log = result.unwrap();
|
||||||
|
assert_eq!(log.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== git_discard tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_discard_changes() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Create and commit a file
|
||||||
|
create_file(&temp_dir, "file.txt", "original content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||||
|
|
||||||
|
// Modify the file
|
||||||
|
create_file(&temp_dir, "file.txt", "modified content");
|
||||||
|
|
||||||
|
// Discard changes
|
||||||
|
let result = git_discard(working_dir.clone(), "file.txt".to_string());
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Verify file contents are restored
|
||||||
|
let content = fs::read_to_string(temp_dir.path().join("file.txt")).unwrap();
|
||||||
|
assert_eq!(content, "original content");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== git_create_branch tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_create_branch() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Initial commit required
|
||||||
|
create_file(&temp_dir, "file.txt", "content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||||
|
|
||||||
|
let result = git_create_branch(working_dir.clone(), "new-branch".to_string());
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Verify branch exists and is current
|
||||||
|
let branches = git_branches(working_dir).unwrap();
|
||||||
|
assert!(branches.iter().any(|b| b.name == "new-branch" && b.is_current));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== git_checkout tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_git_checkout() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Initial commit required
|
||||||
|
create_file(&temp_dir, "file.txt", "content");
|
||||||
|
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
|
||||||
|
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
|
||||||
|
|
||||||
|
// Create a branch
|
||||||
|
run_git_command(&working_dir, &["branch", "other-branch"]).unwrap();
|
||||||
|
|
||||||
|
// Checkout the branch
|
||||||
|
let result = git_checkout(working_dir.clone(), "other-branch".to_string());
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Verify current branch
|
||||||
|
let branches = git_branches(working_dir).unwrap();
|
||||||
|
let current = branches.iter().find(|b| b.is_current);
|
||||||
|
assert!(current.is_some());
|
||||||
|
assert_eq!(current.unwrap().name, "other-branch");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== run_git_command tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_git_command_success() {
|
||||||
|
let temp_dir = create_test_repo();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let result = run_git_command(&working_dir, &["status"]);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_git_command_failure() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let working_dir = temp_dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// This should fail because it's not a git repo
|
||||||
|
let result = run_git_command(&working_dir, &["log"]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_git_command_invalid_dir() {
|
||||||
|
let result = run_git_command("/nonexistent/path", &["status"]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,166 @@
|
|||||||
|
mod achievements;
|
||||||
|
mod bridge_manager;
|
||||||
|
mod clipboard;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod config;
|
||||||
|
mod git;
|
||||||
|
mod notifications;
|
||||||
|
mod provider_bridge;
|
||||||
|
mod providers;
|
||||||
|
mod quick_actions;
|
||||||
|
mod sessions;
|
||||||
|
mod snippets;
|
||||||
|
mod stats;
|
||||||
|
mod temp_manager;
|
||||||
|
mod tray;
|
||||||
mod types;
|
mod types;
|
||||||
|
mod vbs_notification;
|
||||||
|
mod windows_toast;
|
||||||
mod wsl_bridge;
|
mod wsl_bridge;
|
||||||
|
mod wsl_notifications;
|
||||||
|
|
||||||
|
use bridge_manager::create_shared_bridge_manager;
|
||||||
|
use clipboard::*;
|
||||||
|
use commands::load_saved_achievements;
|
||||||
use commands::*;
|
use commands::*;
|
||||||
use wsl_bridge::create_shared_bridge;
|
use git::*;
|
||||||
|
use notifications::*;
|
||||||
|
use quick_actions::*;
|
||||||
|
use sessions::*;
|
||||||
|
use snippets::*;
|
||||||
|
use tauri::Manager;
|
||||||
|
use temp_manager::create_shared_temp_manager;
|
||||||
|
use tray::{setup_tray, should_minimize_to_tray};
|
||||||
|
use vbs_notification::*;
|
||||||
|
use windows_toast::*;
|
||||||
|
use wsl_notifications::*;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let bridge = create_shared_bridge();
|
let bridge_manager = create_shared_bridge_manager();
|
||||||
|
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.manage(bridge)
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.manage(bridge_manager.clone())
|
||||||
|
.manage(temp_manager.clone())
|
||||||
|
.setup(move |app| {
|
||||||
|
// Initialize the app handle in the bridge manager
|
||||||
|
bridge_manager.lock().set_app_handle(app.handle().clone());
|
||||||
|
|
||||||
|
// Clean up any orphaned temp files from previous sessions
|
||||||
|
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
||||||
|
if count > 0 {
|
||||||
|
println!("Cleaned up {} orphaned temp files", count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up system tray
|
||||||
|
if let Err(e) = setup_tray(app.handle()) {
|
||||||
|
eprintln!("Failed to set up system tray: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle window close event for minimize to tray
|
||||||
|
let main_window = app.get_webview_window("main").unwrap();
|
||||||
|
main_window.on_window_event({
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
move |event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
if should_minimize_to_tray(&app_handle) {
|
||||||
|
api.prevent_close();
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
start_claude,
|
start_claude,
|
||||||
stop_claude,
|
stop_claude,
|
||||||
|
interrupt_claude,
|
||||||
send_prompt,
|
send_prompt,
|
||||||
is_claude_running,
|
is_claude_running,
|
||||||
get_working_directory,
|
get_working_directory,
|
||||||
select_wsl_directory,
|
select_wsl_directory,
|
||||||
|
get_config,
|
||||||
|
save_config,
|
||||||
|
get_usage_stats,
|
||||||
|
get_persisted_stats,
|
||||||
|
load_saved_achievements,
|
||||||
|
answer_question,
|
||||||
|
send_windows_notification,
|
||||||
|
send_simple_notification,
|
||||||
|
send_windows_toast,
|
||||||
|
send_notify_send,
|
||||||
|
send_wsl_notification,
|
||||||
|
send_vbs_notification,
|
||||||
|
validate_directory,
|
||||||
|
list_skills,
|
||||||
|
check_for_updates,
|
||||||
|
save_temp_file,
|
||||||
|
register_temp_file,
|
||||||
|
get_temp_files,
|
||||||
|
cleanup_temp_files,
|
||||||
|
cleanup_all_temp_files,
|
||||||
|
cleanup_orphaned_temp_files,
|
||||||
|
get_file_size,
|
||||||
|
list_sessions,
|
||||||
|
save_session,
|
||||||
|
load_session,
|
||||||
|
delete_session,
|
||||||
|
search_sessions,
|
||||||
|
clear_all_sessions,
|
||||||
|
list_snippets,
|
||||||
|
save_snippet,
|
||||||
|
delete_snippet,
|
||||||
|
get_snippet_categories,
|
||||||
|
reset_default_snippets,
|
||||||
|
list_quick_actions,
|
||||||
|
save_quick_action,
|
||||||
|
delete_quick_action,
|
||||||
|
reset_default_quick_actions,
|
||||||
|
git_status,
|
||||||
|
git_diff,
|
||||||
|
git_branches,
|
||||||
|
git_checkout,
|
||||||
|
git_stage,
|
||||||
|
git_unstage,
|
||||||
|
git_stage_all,
|
||||||
|
git_commit,
|
||||||
|
git_push,
|
||||||
|
git_pull,
|
||||||
|
git_fetch,
|
||||||
|
git_log,
|
||||||
|
git_discard,
|
||||||
|
git_create_branch,
|
||||||
|
list_clipboard_entries,
|
||||||
|
capture_clipboard,
|
||||||
|
delete_clipboard_entry,
|
||||||
|
toggle_pin_clipboard_entry,
|
||||||
|
clear_clipboard_history,
|
||||||
|
search_clipboard_entries,
|
||||||
|
get_clipboard_languages,
|
||||||
|
update_clipboard_language,
|
||||||
|
list_directory,
|
||||||
|
read_file_content,
|
||||||
|
write_file_content,
|
||||||
|
create_file,
|
||||||
|
create_directory,
|
||||||
|
delete_file,
|
||||||
|
delete_directory,
|
||||||
|
rename_path,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
use tauri::command;
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
|
||||||
|
// Use notify-send for Linux/WSL
|
||||||
|
let output = Command::new("notify-send")
|
||||||
|
.arg(&title)
|
||||||
|
.arg(&body)
|
||||||
|
.arg("--urgency=normal")
|
||||||
|
.arg("--app-name=Hikari Desktop")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let error = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("notify-send failed: {}", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
|
||||||
|
// Create PowerShell script for Windows Toast Notification
|
||||||
|
let ps_script = format!(
|
||||||
|
r#"
|
||||||
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
||||||
|
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
||||||
|
|
||||||
|
$APP_ID = 'Hikari Desktop'
|
||||||
|
|
||||||
|
$template = @"
|
||||||
|
<toast>
|
||||||
|
<visual>
|
||||||
|
<binding template="ToastText02">
|
||||||
|
<text id="1">{}</text>
|
||||||
|
<text id="2">{}</text>
|
||||||
|
</binding>
|
||||||
|
</visual>
|
||||||
|
<audio src="ms-winsoundevent:Notification.Default" />
|
||||||
|
</toast>
|
||||||
|
"@
|
||||||
|
|
||||||
|
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||||
|
$xml.LoadXml($template)
|
||||||
|
|
||||||
|
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
||||||
|
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
|
||||||
|
"#,
|
||||||
|
title.replace("\"", "`\""),
|
||||||
|
body.replace("\"", "`\"")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
|
||||||
|
let output = Command::new("pwsh.exe")
|
||||||
|
.arg("-NoProfile")
|
||||||
|
.arg("-WindowStyle")
|
||||||
|
.arg("Hidden")
|
||||||
|
.arg("-Command")
|
||||||
|
.arg(&ps_script)
|
||||||
|
.output()
|
||||||
|
.or_else(|_| {
|
||||||
|
Command::new("powershell.exe")
|
||||||
|
.arg("-NoProfile")
|
||||||
|
.arg("-WindowStyle")
|
||||||
|
.arg("Hidden")
|
||||||
|
.arg("-Command")
|
||||||
|
.arg(&ps_script)
|
||||||
|
.output()
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Failed to execute PowerShell: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let error = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("PowerShell script failed: {}", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: Use Windows built-in MSG command for simple notifications
|
||||||
|
#[command]
|
||||||
|
pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> {
|
||||||
|
let message = format!("{}\n\n{}", title, body);
|
||||||
|
|
||||||
|
Command::new("cmd.exe")
|
||||||
|
.arg("/c")
|
||||||
|
.arg("msg")
|
||||||
|
.arg("*")
|
||||||
|
.arg(&message)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to send message: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
// This provider wraps the Claude CLI subprocess.
|
||||||
|
// It will be actively used once providers are fully integrated with BridgeManager.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
use crate::providers::traits::{
|
||||||
|
LlmProvider, ModelInfo, ProviderCapabilities, ProviderConfig, ProviderStreamEvent,
|
||||||
|
QuestionOption, StreamCallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
fn detect_wsl() -> bool {
|
||||||
|
if let Ok(version) = std::fs::read_to_string("/proc/version") {
|
||||||
|
let version_lower = version.to_lowercase();
|
||||||
|
if version_lower.contains("microsoft") || version_lower.contains("wsl") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if std::env::var("WSL_DISTRO_NAME").is_ok() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_claude_binary() -> Option<String> {
|
||||||
|
let home = std::env::var("HOME").ok()?;
|
||||||
|
let paths_to_check = [
|
||||||
|
format!("{}/.local/bin/claude", home),
|
||||||
|
format!("{}/.claude/local/claude", home),
|
||||||
|
"/usr/local/bin/claude".to_string(),
|
||||||
|
"/usr/bin/claude".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in &paths_to_check {
|
||||||
|
if std::path::Path::new(path).exists() {
|
||||||
|
return Some(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(output) = Command::new("which").arg("claude").output() {
|
||||||
|
if output.status.success() {
|
||||||
|
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !path.is_empty() {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ClaudeCliProvider {
|
||||||
|
config: ProviderConfig,
|
||||||
|
process: Option<Child>,
|
||||||
|
stdin: Option<ChildStdin>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
mcp_config_file: Option<NamedTempFile>,
|
||||||
|
is_running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClaudeCliProvider {
|
||||||
|
pub fn new(config: ProviderConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
process: None,
|
||||||
|
stdin: None,
|
||||||
|
session_id: None,
|
||||||
|
mcp_config_file: None,
|
||||||
|
is_running: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_command(&mut self) -> Result<Command, String> {
|
||||||
|
let mcp_config_path = if let Some(ref mcp_json) = self.config.mcp_servers_json {
|
||||||
|
if !mcp_json.trim().is_empty() {
|
||||||
|
serde_json::from_str::<serde_json::Value>(mcp_json)
|
||||||
|
.map_err(|e| format!("Invalid MCP servers JSON: {}", e))?;
|
||||||
|
|
||||||
|
let mut temp_file = NamedTempFile::new()
|
||||||
|
.map_err(|e| format!("Failed to create temp file for MCP config: {}", e))?;
|
||||||
|
temp_file
|
||||||
|
.write_all(mcp_json.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write MCP config: {}", e))?;
|
||||||
|
temp_file
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| format!("Failed to flush MCP config: {}", e))?;
|
||||||
|
|
||||||
|
let path = temp_file.path().to_string_lossy().to_string();
|
||||||
|
self.mcp_config_file = Some(temp_file);
|
||||||
|
Some(path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_wsl = detect_wsl();
|
||||||
|
let working_dir = &self.config.working_directory;
|
||||||
|
|
||||||
|
let command = if is_wsl {
|
||||||
|
let claude_path = find_claude_binary().ok_or_else(|| {
|
||||||
|
"Could not find claude binary. Is Claude Code installed?".to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut cmd = Command::new(&claude_path);
|
||||||
|
cmd.args([
|
||||||
|
"--output-format",
|
||||||
|
"stream-json",
|
||||||
|
"--input-format",
|
||||||
|
"stream-json",
|
||||||
|
"--verbose",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if let Some(ref model) = self.config.model {
|
||||||
|
if !model.is_empty() {
|
||||||
|
cmd.args(["--model", model]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for tool in &self.config.allowed_tools {
|
||||||
|
cmd.args(["--allowedTools", tool]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref instructions) = self.config.custom_instructions {
|
||||||
|
if !instructions.is_empty() {
|
||||||
|
cmd.args(["--system-prompt", instructions]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref mcp_path) = mcp_config_path {
|
||||||
|
cmd.args(["--mcp-config", mcp_path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.current_dir(working_dir);
|
||||||
|
|
||||||
|
if let Some(ref api_key) = self.config.api_key {
|
||||||
|
if !api_key.is_empty() {
|
||||||
|
cmd.env("ANTHROPIC_API_KEY", api_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd
|
||||||
|
} else {
|
||||||
|
let mut cmd = Command::new("wsl");
|
||||||
|
|
||||||
|
let mut claude_cmd = format!("cd '{}' && ", working_dir);
|
||||||
|
|
||||||
|
if let Some(ref api_key) = self.config.api_key {
|
||||||
|
if !api_key.is_empty() {
|
||||||
|
claude_cmd.push_str(&format!("ANTHROPIC_API_KEY='{}' ", api_key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claude_cmd.push_str(
|
||||||
|
"claude --output-format stream-json --input-format stream-json --verbose",
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(ref model) = self.config.model {
|
||||||
|
if !model.is_empty() {
|
||||||
|
claude_cmd.push_str(&format!(" --model '{}'", model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for tool in &self.config.allowed_tools {
|
||||||
|
claude_cmd.push_str(&format!(" --allowedTools '{}'", tool));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref instructions) = self.config.custom_instructions {
|
||||||
|
if !instructions.is_empty() {
|
||||||
|
let escaped = instructions.replace('\'', "'\\''");
|
||||||
|
claude_cmd.push_str(&format!(" --system-prompt '{}'", escaped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref mcp_path) = mcp_config_path {
|
||||||
|
claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||||
|
|
||||||
|
cmd
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmProvider for ClaudeCliProvider {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Claude CLI"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> ProviderCapabilities {
|
||||||
|
ProviderCapabilities {
|
||||||
|
supports_streaming: true,
|
||||||
|
supports_tools: true,
|
||||||
|
supports_vision: true,
|
||||||
|
supports_thinking: true,
|
||||||
|
supports_mcp: true,
|
||||||
|
supports_resume_session: true,
|
||||||
|
max_context_tokens: Some(200_000),
|
||||||
|
available_models: vec![
|
||||||
|
ModelInfo {
|
||||||
|
id: "claude-sonnet-4-20250514".to_string(),
|
||||||
|
name: "Claude Sonnet 4".to_string(),
|
||||||
|
description: Some("Fast and intelligent".to_string()),
|
||||||
|
context_window: Some(200_000),
|
||||||
|
input_cost_per_mtok: Some(3.0),
|
||||||
|
output_cost_per_mtok: Some(15.0),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "claude-opus-4-20250514".to_string(),
|
||||||
|
name: "Claude Opus 4".to_string(),
|
||||||
|
description: Some("Most capable model".to_string()),
|
||||||
|
context_window: Some(200_000),
|
||||||
|
input_cost_per_mtok: Some(15.0),
|
||||||
|
output_cost_per_mtok: Some(75.0),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "claude-3-5-haiku-20241022".to_string(),
|
||||||
|
name: "Claude 3.5 Haiku".to_string(),
|
||||||
|
description: Some("Fast and efficient".to_string()),
|
||||||
|
context_window: Some(200_000),
|
||||||
|
input_cost_per_mtok: Some(1.0),
|
||||||
|
output_cost_per_mtok: Some(5.0),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(&mut self, callback: StreamCallback) -> Result<(), String> {
|
||||||
|
if self.process.is_some() {
|
||||||
|
return Err("Process already running".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(ProviderStreamEvent::Connected { session_id: None });
|
||||||
|
|
||||||
|
let mut command = self.build_command()?;
|
||||||
|
|
||||||
|
command
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Failed to spawn process: {}", e))?;
|
||||||
|
|
||||||
|
let stdin = child.stdin.take();
|
||||||
|
let stdout = child.stdout.take();
|
||||||
|
let stderr = child.stderr.take();
|
||||||
|
|
||||||
|
self.stdin = stdin;
|
||||||
|
self.process = Some(child);
|
||||||
|
self.is_running.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let is_running = self.is_running.clone();
|
||||||
|
let callback = Arc::new(callback);
|
||||||
|
|
||||||
|
if let Some(stdout) = stdout {
|
||||||
|
let callback_clone = callback.clone();
|
||||||
|
let is_running_clone = is_running.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
handle_stdout(stdout, callback_clone, is_running_clone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stderr) = stderr {
|
||||||
|
let callback_clone = callback.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
handle_stderr(stderr, callback_clone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&mut self) -> Result<(), String> {
|
||||||
|
if let Some(mut process) = self.process.take() {
|
||||||
|
let _ = process.kill();
|
||||||
|
let _ = process.wait();
|
||||||
|
}
|
||||||
|
self.stdin = None;
|
||||||
|
self.session_id = None;
|
||||||
|
self.mcp_config_file = None;
|
||||||
|
self.is_running.store(false, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_running(&self) -> bool {
|
||||||
|
self.is_running.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_message(&mut self, message: &str) -> Result<(), String> {
|
||||||
|
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
|
||||||
|
|
||||||
|
let input = serde_json::json!({
|
||||||
|
"type": "user",
|
||||||
|
"message": {
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": message
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
stdin
|
||||||
|
.write_all(format!("{}\n", json_line).as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
||||||
|
|
||||||
|
stdin
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_tool_result(
|
||||||
|
&mut self,
|
||||||
|
tool_use_id: &str,
|
||||||
|
result: serde_json::Value,
|
||||||
|
_is_error: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
|
||||||
|
|
||||||
|
let content_str = serde_json::to_string(&result).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let input = serde_json::json!({
|
||||||
|
"type": "user",
|
||||||
|
"message": {
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": tool_use_id,
|
||||||
|
"content": content_str
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
stdin
|
||||||
|
.write_all(format!("{}\n", json_line).as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
||||||
|
|
||||||
|
stdin
|
||||||
|
.flush()
|
||||||
|
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_model(&self) -> Option<String> {
|
||||||
|
self.config.model.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_working_directory(&self) -> &str {
|
||||||
|
&self.config.working_directory
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn interrupt(&mut self) -> Result<(), String> {
|
||||||
|
if let Some(mut process) = self.process.take() {
|
||||||
|
let _ = process.kill();
|
||||||
|
let _ = process.wait();
|
||||||
|
self.stdin = None;
|
||||||
|
self.is_running.store(false, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("No active process to interrupt".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_stdout(
|
||||||
|
stdout: std::process::ChildStdout,
|
||||||
|
callback: Arc<StreamCallback>,
|
||||||
|
is_running: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let reader = BufReader::new(stdout);
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
match line {
|
||||||
|
Ok(line) if !line.is_empty() => {
|
||||||
|
if let Err(e) = process_json_line(&line, &callback) {
|
||||||
|
eprintln!("Error processing line: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error reading stdout: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is_running.store(false, Ordering::SeqCst);
|
||||||
|
callback(ProviderStreamEvent::Disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_stderr(stderr: std::process::ChildStderr, callback: Arc<StreamCallback>) {
|
||||||
|
let reader = BufReader::new(stderr);
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
match line {
|
||||||
|
Ok(line) if !line.is_empty() => {
|
||||||
|
callback(ProviderStreamEvent::Error { message: line });
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_json_line(line: &str, callback: &StreamCallback) -> Result<(), String> {
|
||||||
|
let message: serde_json::Value = serde_json::from_str(line)
|
||||||
|
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
|
||||||
|
|
||||||
|
let msg_type = message.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
match msg_type {
|
||||||
|
"system" => {
|
||||||
|
let subtype = message
|
||||||
|
.get("subtype")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if subtype == "init" {
|
||||||
|
let session_id = message
|
||||||
|
.get("session_id")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
callback(ProviderStreamEvent::Connected { session_id });
|
||||||
|
|
||||||
|
if let Some(cwd) = message.get("cwd").and_then(|c| c.as_str()) {
|
||||||
|
callback(ProviderStreamEvent::WorkingDirectory {
|
||||||
|
path: cwd.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"assistant" => {
|
||||||
|
if let Some(msg) = message.get("message") {
|
||||||
|
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
|
||||||
|
for block in content {
|
||||||
|
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
match block_type {
|
||||||
|
"text" => {
|
||||||
|
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||||
|
callback(ProviderStreamEvent::TextDelta {
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"thinking" => {
|
||||||
|
if let Some(thinking) =
|
||||||
|
block.get("thinking").and_then(|t| t.as_str())
|
||||||
|
{
|
||||||
|
callback(ProviderStreamEvent::ThinkingDelta {
|
||||||
|
text: thinking.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tool_use" => {
|
||||||
|
let id = block
|
||||||
|
.get("id")
|
||||||
|
.and_then(|i| i.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let name = block
|
||||||
|
.get("name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let input = block
|
||||||
|
.get("input")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::Value::Null);
|
||||||
|
|
||||||
|
callback(ProviderStreamEvent::ToolUseStart {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
callback(ProviderStreamEvent::ToolUseEnd { id, input });
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract usage if available
|
||||||
|
let usage = msg.get("usage").and_then(|u| {
|
||||||
|
let input_tokens = u.get("input_tokens").and_then(|t| t.as_u64())?;
|
||||||
|
let output_tokens = u.get("output_tokens").and_then(|t| t.as_u64())?;
|
||||||
|
let model = msg
|
||||||
|
.get("model")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
Some(crate::providers::ProviderUsage {
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
callback(ProviderStreamEvent::MessageComplete {
|
||||||
|
content: vec![],
|
||||||
|
usage,
|
||||||
|
stop_reason: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"result" => {
|
||||||
|
let subtype = message
|
||||||
|
.get("subtype")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Handle permission denials
|
||||||
|
if let Some(denials) = message
|
||||||
|
.get("permission_denials")
|
||||||
|
.and_then(|d| d.as_array())
|
||||||
|
{
|
||||||
|
for denial in denials {
|
||||||
|
let tool_name = denial
|
||||||
|
.get("tool_name")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Check if this is an AskUserQuestion
|
||||||
|
if tool_name == "AskUserQuestion" {
|
||||||
|
if let Some(tool_input) = denial.get("tool_input") {
|
||||||
|
if let Some(questions) =
|
||||||
|
tool_input.get("questions").and_then(|q| q.as_array())
|
||||||
|
{
|
||||||
|
if let Some(first_question) = questions.first() {
|
||||||
|
let question = first_question
|
||||||
|
.get("question")
|
||||||
|
.and_then(|q| q.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let header = first_question
|
||||||
|
.get("header")
|
||||||
|
.and_then(|h| h.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let multi_select = first_question
|
||||||
|
.get("multiSelect")
|
||||||
|
.and_then(|m| m.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let options: Vec<QuestionOption> = first_question
|
||||||
|
.get("options")
|
||||||
|
.and_then(|opts| opts.as_array())
|
||||||
|
.map(|opts| {
|
||||||
|
opts.iter()
|
||||||
|
.filter_map(|opt| {
|
||||||
|
let label = opt
|
||||||
|
.get("label")
|
||||||
|
.and_then(|l| l.as_str())?;
|
||||||
|
let description = opt
|
||||||
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
Some(QuestionOption {
|
||||||
|
label: label.to_string(),
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let id = denial
|
||||||
|
.get("tool_use_id")
|
||||||
|
.and_then(|i| i.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
callback(ProviderStreamEvent::Question {
|
||||||
|
id,
|
||||||
|
question,
|
||||||
|
header,
|
||||||
|
options,
|
||||||
|
multi_select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let id = denial
|
||||||
|
.get("tool_use_id")
|
||||||
|
.and_then(|i| i.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let tool_input = denial
|
||||||
|
.get("tool_input")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::Value::Null);
|
||||||
|
let description = format_tool_description(&tool_name, &tool_input);
|
||||||
|
|
||||||
|
callback(ProviderStreamEvent::PermissionRequest {
|
||||||
|
id,
|
||||||
|
tool_name,
|
||||||
|
tool_input,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if subtype != "success" {
|
||||||
|
if let Some(result) = message.get("result").and_then(|r| r.as_str()) {
|
||||||
|
callback(ProviderStreamEvent::Error {
|
||||||
|
message: result.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||||
|
match name {
|
||||||
|
"Read" => {
|
||||||
|
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
|
||||||
|
format!("Reading file: {}", path)
|
||||||
|
} else {
|
||||||
|
"Reading file...".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Glob" => {
|
||||||
|
if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) {
|
||||||
|
format!("Searching for files: {}", pattern)
|
||||||
|
} else {
|
||||||
|
"Searching for files...".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Grep" => {
|
||||||
|
if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) {
|
||||||
|
format!("Searching for: {}", pattern)
|
||||||
|
} else {
|
||||||
|
"Searching in files...".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Edit" | "Write" => {
|
||||||
|
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
|
||||||
|
format!("Editing: {}", path)
|
||||||
|
} else {
|
||||||
|
"Editing file...".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Bash" => {
|
||||||
|
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
|
||||||
|
let truncated = if cmd.len() > 50 {
|
||||||
|
format!("{}...", &cmd[..50])
|
||||||
|
} else {
|
||||||
|
cmd.to_string()
|
||||||
|
};
|
||||||
|
format!("Running: {}", truncated)
|
||||||
|
} else {
|
||||||
|
"Running command...".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => format!("Using tool: {}", name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_name() {
|
||||||
|
let config = ProviderConfig::default();
|
||||||
|
let provider = ClaudeCliProvider::new(config);
|
||||||
|
assert_eq!(provider.name(), "Claude CLI");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capabilities() {
|
||||||
|
let config = ProviderConfig::default();
|
||||||
|
let provider = ClaudeCliProvider::new(config);
|
||||||
|
let caps = provider.capabilities();
|
||||||
|
|
||||||
|
assert!(caps.supports_streaming);
|
||||||
|
assert!(caps.supports_tools);
|
||||||
|
assert!(caps.supports_vision);
|
||||||
|
assert!(caps.supports_thinking);
|
||||||
|
assert!(caps.supports_mcp);
|
||||||
|
assert!(caps.supports_resume_session);
|
||||||
|
assert_eq!(caps.max_context_tokens, Some(200_000));
|
||||||
|
assert!(!caps.available_models.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_running_initial() {
|
||||||
|
let config = ProviderConfig::default();
|
||||||
|
let provider = ClaudeCliProvider::new(config);
|
||||||
|
assert!(!provider.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_working_directory() {
|
||||||
|
let config = ProviderConfig {
|
||||||
|
working_directory: "/home/test".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let provider = ClaudeCliProvider::new(config);
|
||||||
|
assert_eq!(provider.get_working_directory(), "/home/test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_model() {
|
||||||
|
let config = ProviderConfig {
|
||||||
|
model: Some("claude-sonnet-4-20250514".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let provider = ClaudeCliProvider::new(config);
|
||||||
|
assert_eq!(
|
||||||
|
provider.get_model(),
|
||||||
|
Some("claude-sonnet-4-20250514".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_tool_description() {
|
||||||
|
let input = serde_json::json!({"file_path": "/test/file.rs"});
|
||||||
|
assert_eq!(
|
||||||
|
format_tool_description("Read", &input),
|
||||||
|
"Reading file: /test/file.rs"
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = serde_json::json!({"pattern": "*.rs"});
|
||||||
|
assert_eq!(
|
||||||
|
format_tool_description("Glob", &input),
|
||||||
|
"Searching for files: *.rs"
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = serde_json::json!({"command": "ls -la"});
|
||||||
|
assert_eq!(format_tool_description("Bash", &input), "Running: ls -la");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
mod claude_cli;
|
||||||
|
mod ollama;
|
||||||
|
mod traits;
|
||||||
|
|
||||||
|
// Re-exports for when providers are fully integrated
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use claude_cli::ClaudeCliProvider;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use ollama::OllamaProvider;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use traits::{
|
||||||
|
LlmProvider, ModelInfo, ProviderCapabilities, ProviderConfig, ProviderMessage,
|
||||||
|
ProviderStreamEvent, ProviderUsage, QuestionOption, StreamCallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ProviderType {
|
||||||
|
#[default]
|
||||||
|
ClaudeCli,
|
||||||
|
Ollama,
|
||||||
|
OpenAi,
|
||||||
|
Anthropic,
|
||||||
|
Gemini,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl ProviderType {
|
||||||
|
pub fn display_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProviderType::ClaudeCli => "Claude CLI",
|
||||||
|
ProviderType::Ollama => "Ollama (Local)",
|
||||||
|
ProviderType::OpenAi => "OpenAI API",
|
||||||
|
ProviderType::Anthropic => "Anthropic API",
|
||||||
|
ProviderType::Gemini => "Google Gemini",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn description(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProviderType::ClaudeCli => "Use Claude Code CLI for AI assistance",
|
||||||
|
ProviderType::Ollama => "Use locally running Ollama models",
|
||||||
|
ProviderType::OpenAi => "Direct OpenAI API access (GPT-4, etc.)",
|
||||||
|
ProviderType::Anthropic => "Direct Anthropic API access (Claude models)",
|
||||||
|
ProviderType::Gemini => "Direct Google Gemini API access",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn requires_api_key(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
ProviderType::OpenAi | ProviderType::Anthropic | ProviderType::Gemini
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: The new providers (OpenAI, Anthropic, Gemini) are implemented directly
|
||||||
|
// in provider_bridge.rs using the Bridge pattern rather than the LlmProvider trait.
|
||||||
|
// This simplifies the architecture while still providing full functionality.
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn create_provider(
|
||||||
|
provider_type: ProviderType,
|
||||||
|
config: ProviderConfig,
|
||||||
|
) -> Box<dyn LlmProvider> {
|
||||||
|
match provider_type {
|
||||||
|
ProviderType::ClaudeCli => Box::new(ClaudeCliProvider::new(config)),
|
||||||
|
ProviderType::Ollama => Box::new(OllamaProvider::new(config)),
|
||||||
|
// The new API-based providers are handled in provider_bridge.rs
|
||||||
|
ProviderType::OpenAi | ProviderType::Anthropic | ProviderType::Gemini => {
|
||||||
|
// These providers use the Bridge pattern in provider_bridge.rs
|
||||||
|
// Fall back to Claude CLI for trait-based usage
|
||||||
|
Box::new(ClaudeCliProvider::new(config))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_display_name() {
|
||||||
|
assert_eq!(ProviderType::ClaudeCli.display_name(), "Claude CLI");
|
||||||
|
assert_eq!(ProviderType::Ollama.display_name(), "Ollama (Local)");
|
||||||
|
assert_eq!(ProviderType::OpenAi.display_name(), "OpenAI API");
|
||||||
|
assert_eq!(ProviderType::Anthropic.display_name(), "Anthropic API");
|
||||||
|
assert_eq!(ProviderType::Gemini.display_name(), "Google Gemini");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_default() {
|
||||||
|
let default: ProviderType = Default::default();
|
||||||
|
assert_eq!(default, ProviderType::ClaudeCli);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_serialization() {
|
||||||
|
let claude = ProviderType::ClaudeCli;
|
||||||
|
let json = serde_json::to_string(&claude).unwrap();
|
||||||
|
assert_eq!(json, "\"claude_cli\"");
|
||||||
|
|
||||||
|
let ollama = ProviderType::Ollama;
|
||||||
|
let json = serde_json::to_string(&ollama).unwrap();
|
||||||
|
assert_eq!(json, "\"ollama\"");
|
||||||
|
|
||||||
|
let openai = ProviderType::OpenAi;
|
||||||
|
let json = serde_json::to_string(&openai).unwrap();
|
||||||
|
assert_eq!(json, "\"open_ai\"");
|
||||||
|
|
||||||
|
let anthropic = ProviderType::Anthropic;
|
||||||
|
let json = serde_json::to_string(&anthropic).unwrap();
|
||||||
|
assert_eq!(json, "\"anthropic\"");
|
||||||
|
|
||||||
|
let gemini = ProviderType::Gemini;
|
||||||
|
let json = serde_json::to_string(&gemini).unwrap();
|
||||||
|
assert_eq!(json, "\"gemini\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_deserialization() {
|
||||||
|
let claude: ProviderType = serde_json::from_str("\"claude_cli\"").unwrap();
|
||||||
|
assert_eq!(claude, ProviderType::ClaudeCli);
|
||||||
|
|
||||||
|
let ollama: ProviderType = serde_json::from_str("\"ollama\"").unwrap();
|
||||||
|
assert_eq!(ollama, ProviderType::Ollama);
|
||||||
|
|
||||||
|
let openai: ProviderType = serde_json::from_str("\"open_ai\"").unwrap();
|
||||||
|
assert_eq!(openai, ProviderType::OpenAi);
|
||||||
|
|
||||||
|
let anthropic: ProviderType = serde_json::from_str("\"anthropic\"").unwrap();
|
||||||
|
assert_eq!(anthropic, ProviderType::Anthropic);
|
||||||
|
|
||||||
|
let gemini: ProviderType = serde_json::from_str("\"gemini\"").unwrap();
|
||||||
|
assert_eq!(gemini, ProviderType::Gemini);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_type_requires_api_key() {
|
||||||
|
assert!(!ProviderType::ClaudeCli.requires_api_key());
|
||||||
|
assert!(!ProviderType::Ollama.requires_api_key());
|
||||||
|
assert!(ProviderType::OpenAi.requires_api_key());
|
||||||
|
assert!(ProviderType::Anthropic.requires_api_key());
|
||||||
|
assert!(ProviderType::Gemini.requires_api_key());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
// This provider connects to a local Ollama instance for LLM inference.
|
||||||
|
// It will be actively used once providers are fully integrated with BridgeManager.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::providers::traits::{
|
||||||
|
LlmProvider, ModelInfo, ProviderCapabilities, ProviderConfig, ProviderMessage,
|
||||||
|
ProviderStreamEvent, ProviderUsage, StreamCallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OllamaChatRequest {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<OllamaMessage>,
|
||||||
|
stream: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
system: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct OllamaMessage {
|
||||||
|
role: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OllamaChatResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
message: Option<OllamaResponseMessage>,
|
||||||
|
#[serde(default)]
|
||||||
|
done: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
eval_count: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
prompt_eval_count: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OllamaResponseMessage {
|
||||||
|
#[serde(default)]
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OllamaTagsResponse {
|
||||||
|
models: Vec<OllamaModelInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OllamaModelInfo {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
details: Option<OllamaModelDetails>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OllamaModelDetails {
|
||||||
|
#[serde(default)]
|
||||||
|
parameter_size: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
family: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OllamaProvider {
|
||||||
|
config: ProviderConfig,
|
||||||
|
client: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
is_running: Arc<AtomicBool>,
|
||||||
|
conversation_history: Vec<OllamaMessage>,
|
||||||
|
cancel_tx: Option<mpsc::Sender<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OllamaProvider {
|
||||||
|
pub fn new(config: ProviderConfig) -> Self {
|
||||||
|
let base_url = config
|
||||||
|
.api_base_url
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| DEFAULT_OLLAMA_URL.to_string());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
base_url,
|
||||||
|
is_running: Arc::new(AtomicBool::new(false)),
|
||||||
|
conversation_history: Vec::new(),
|
||||||
|
cancel_tx: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_available_models(&self) -> Vec<ModelInfo> {
|
||||||
|
let url = format!("{}/api/tags", self.base_url);
|
||||||
|
|
||||||
|
match self.client.get(&url).send().await {
|
||||||
|
Ok(response) => {
|
||||||
|
if let Ok(tags) = response.json::<OllamaTagsResponse>().await {
|
||||||
|
tags.models
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| {
|
||||||
|
let description = m.details.as_ref().map(|d| {
|
||||||
|
let mut desc_parts = Vec::new();
|
||||||
|
if let Some(ref family) = d.family {
|
||||||
|
desc_parts.push(family.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref size) = d.parameter_size {
|
||||||
|
desc_parts.push(format!("{} parameters", size));
|
||||||
|
}
|
||||||
|
if desc_parts.is_empty() {
|
||||||
|
"Local model".to_string()
|
||||||
|
} else {
|
||||||
|
desc_parts.join(" - ")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ModelInfo {
|
||||||
|
id: m.name.clone(),
|
||||||
|
name: m.name,
|
||||||
|
description,
|
||||||
|
context_window: None,
|
||||||
|
input_cost_per_mtok: Some(0.0), // Local = free!
|
||||||
|
output_cost_per_mtok: Some(0.0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
default_ollama_models()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => default_ollama_models(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_chat(
|
||||||
|
&mut self,
|
||||||
|
callback: Arc<StreamCallback>,
|
||||||
|
cancel_rx: mpsc::Receiver<()>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let model = self
|
||||||
|
.config
|
||||||
|
.model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "llama3.2".to_string());
|
||||||
|
|
||||||
|
let request = OllamaChatRequest {
|
||||||
|
model: model.clone(),
|
||||||
|
messages: self.conversation_history.clone(),
|
||||||
|
stream: true,
|
||||||
|
system: self.config.custom_instructions.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/api/chat", self.base_url);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to connect to Ollama: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
return Err(format!("Ollama error ({}): {}", status, error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut full_response = String::new();
|
||||||
|
let mut total_input_tokens: u64 = 0;
|
||||||
|
let mut total_output_tokens: u64 = 0;
|
||||||
|
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
let mut cancel_rx = cancel_rx;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel_rx.recv() => {
|
||||||
|
callback(ProviderStreamEvent::Disconnected);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
chunk = stream.next() => {
|
||||||
|
match chunk {
|
||||||
|
Some(Ok(bytes)) => {
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(response) = serde_json::from_str::<OllamaChatResponse>(line) {
|
||||||
|
if let Some(msg) = &response.message {
|
||||||
|
if !msg.content.is_empty() {
|
||||||
|
full_response.push_str(&msg.content);
|
||||||
|
callback(ProviderStreamEvent::TextDelta {
|
||||||
|
text: msg.content.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tokens) = response.prompt_eval_count {
|
||||||
|
total_input_tokens = tokens;
|
||||||
|
}
|
||||||
|
if let Some(tokens) = response.eval_count {
|
||||||
|
total_output_tokens = tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.done {
|
||||||
|
self.conversation_history.push(OllamaMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: full_response.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
callback(ProviderStreamEvent::MessageComplete {
|
||||||
|
content: vec![ProviderMessage::Text {
|
||||||
|
content: full_response,
|
||||||
|
}],
|
||||||
|
usage: Some(ProviderUsage {
|
||||||
|
input_tokens: total_input_tokens,
|
||||||
|
output_tokens: total_output_tokens,
|
||||||
|
model,
|
||||||
|
}),
|
||||||
|
stop_reason: Some("end_turn".to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Err(e)) => {
|
||||||
|
return Err(format!("Stream error: {}", e));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmProvider for OllamaProvider {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Ollama"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> ProviderCapabilities {
|
||||||
|
ProviderCapabilities {
|
||||||
|
supports_streaming: true,
|
||||||
|
supports_tools: false, // Ollama doesn't support tools natively yet
|
||||||
|
supports_vision: true, // Some models support vision
|
||||||
|
supports_thinking: false,
|
||||||
|
supports_mcp: false,
|
||||||
|
supports_resume_session: false,
|
||||||
|
max_context_tokens: None, // Varies by model
|
||||||
|
available_models: default_ollama_models(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(&mut self, callback: StreamCallback) -> Result<(), String> {
|
||||||
|
if self.is_running.load(Ordering::SeqCst) {
|
||||||
|
return Err("Provider already running".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Ollama is reachable
|
||||||
|
let url = format!("{}/api/tags", self.base_url);
|
||||||
|
self.client.get(&url).send().await.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Cannot connect to Ollama at {}. Is it running? Error: {}",
|
||||||
|
self.base_url, e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.is_running.store(true, Ordering::SeqCst);
|
||||||
|
self.conversation_history.clear();
|
||||||
|
|
||||||
|
// Create cancellation channel
|
||||||
|
let (tx, _rx) = mpsc::channel(1);
|
||||||
|
self.cancel_tx = Some(tx);
|
||||||
|
|
||||||
|
callback(ProviderStreamEvent::Connected { session_id: None });
|
||||||
|
|
||||||
|
// Fetch and report available models
|
||||||
|
let models = self.fetch_available_models().await;
|
||||||
|
if !models.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"[Ollama] Available models: {:?}",
|
||||||
|
models.iter().map(|m| &m.id).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&mut self) -> Result<(), String> {
|
||||||
|
self.is_running.store(false, Ordering::SeqCst);
|
||||||
|
self.conversation_history.clear();
|
||||||
|
|
||||||
|
if let Some(tx) = self.cancel_tx.take() {
|
||||||
|
let _ = tx.send(()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_running(&self) -> bool {
|
||||||
|
self.is_running.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_message(&mut self, message: &str) -> Result<(), String> {
|
||||||
|
if !self.is_running.load(Ordering::SeqCst) {
|
||||||
|
return Err("Provider not running".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user message to history
|
||||||
|
self.conversation_history.push(OllamaMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: message.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new cancel channel for this request
|
||||||
|
let (tx, _rx) = mpsc::channel(1);
|
||||||
|
self.cancel_tx = Some(tx);
|
||||||
|
|
||||||
|
// We need a callback here, but we don't have access to it in send_message
|
||||||
|
// This is a limitation of the current trait design
|
||||||
|
// For now, we'll need to refactor to handle streaming properly
|
||||||
|
// The callback should be stored from the start() call
|
||||||
|
|
||||||
|
// For the MVP, we'll emit events directly
|
||||||
|
// In a real implementation, we'd need to restructure this
|
||||||
|
|
||||||
|
Err("send_message needs refactoring to work with stored callback".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_tool_result(
|
||||||
|
&mut self,
|
||||||
|
_tool_use_id: &str,
|
||||||
|
_result: serde_json::Value,
|
||||||
|
_is_error: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Ollama doesn't support tool use natively
|
||||||
|
Err("Ollama does not support tool use".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_model(&self) -> Option<String> {
|
||||||
|
self.config.model.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_working_directory(&self) -> &str {
|
||||||
|
&self.config.working_directory
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn interrupt(&mut self) -> Result<(), String> {
|
||||||
|
if let Some(tx) = self.cancel_tx.take() {
|
||||||
|
let _ = tx.send(()).await;
|
||||||
|
}
|
||||||
|
self.is_running.store(false, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ollama_models() -> Vec<ModelInfo> {
|
||||||
|
vec![
|
||||||
|
ModelInfo {
|
||||||
|
id: "llama3.2".to_string(),
|
||||||
|
name: "Llama 3.2".to_string(),
|
||||||
|
description: Some("Meta's latest compact model".to_string()),
|
||||||
|
context_window: Some(128_000),
|
||||||
|
input_cost_per_mtok: Some(0.0),
|
||||||
|
output_cost_per_mtok: Some(0.0),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "llama3.2:1b".to_string(),
|
||||||
|
name: "Llama 3.2 1B".to_string(),
|
||||||
|
description: Some("Smallest Llama 3.2 variant".to_string()),
|
||||||
|
context_window: Some(128_000),
|
||||||
|
input_cost_per_mtok: Some(0.0),
|
||||||
|
output_cost_per_mtok: Some(0.0),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "qwen2.5-coder".to_string(),
|
||||||
|
name: "Qwen 2.5 Coder".to_string(),
|
||||||
|
description: Some("Alibaba's coding-focused model".to_string()),
|
||||||
|
context_window: Some(32_000),
|
||||||
|
input_cost_per_mtok: Some(0.0),
|
||||||
|
output_cost_per_mtok: Some(0.0),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "deepseek-coder-v2".to_string(),
|
||||||
|
name: "DeepSeek Coder V2".to_string(),
|
||||||
|
description: Some("DeepSeek's coding model".to_string()),
|
||||||
|
context_window: Some(128_000),
|
||||||
|
input_cost_per_mtok: Some(0.0),
|
||||||
|
output_cost_per_mtok: Some(0.0),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "mistral".to_string(),
|
||||||
|
name: "Mistral 7B".to_string(),
|
||||||
|
description: Some("Fast and capable".to_string()),
|
||||||
|
context_window: Some(32_000),
|
||||||
|
input_cost_per_mtok: Some(0.0),
|
||||||
|
output_cost_per_mtok: Some(0.0),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "gemma2".to_string(),
|
||||||
|
name: "Gemma 2".to_string(),
|
||||||
|
description: Some("Google's open model".to_string()),
|
||||||
|
context_window: Some(8_000),
|
||||||
|
input_cost_per_mtok: Some(0.0),
|
||||||
|
output_cost_per_mtok: Some(0.0),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_name() {
|
||||||
|
let config = ProviderConfig::default();
|
||||||
|
let provider = OllamaProvider::new(config);
|
||||||
|
assert_eq!(provider.name(), "Ollama");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capabilities() {
|
||||||
|
let config = ProviderConfig::default();
|
||||||
|
let provider = OllamaProvider::new(config);
|
||||||
|
let caps = provider.capabilities();
|
||||||
|
|
||||||
|
assert!(caps.supports_streaming);
|
||||||
|
assert!(!caps.supports_tools);
|
||||||
|
assert!(!caps.supports_mcp);
|
||||||
|
assert!(!caps.supports_resume_session);
|
||||||
|
assert!(!caps.available_models.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_url() {
|
||||||
|
let config = ProviderConfig::default();
|
||||||
|
let provider = OllamaProvider::new(config);
|
||||||
|
assert_eq!(provider.base_url, DEFAULT_OLLAMA_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_custom_url() {
|
||||||
|
let config = ProviderConfig {
|
||||||
|
api_base_url: Some("http://custom:8080".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let provider = OllamaProvider::new(config);
|
||||||
|
assert_eq!(provider.base_url, "http://custom:8080");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_running_initial() {
|
||||||
|
let config = ProviderConfig::default();
|
||||||
|
let provider = OllamaProvider::new(config);
|
||||||
|
assert!(!provider.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_ollama_models() {
|
||||||
|
let models = default_ollama_models();
|
||||||
|
assert!(!models.is_empty());
|
||||||
|
|
||||||
|
// All models should be free (local)
|
||||||
|
for model in &models {
|
||||||
|
assert_eq!(model.input_cost_per_mtok, Some(0.0));
|
||||||
|
assert_eq!(model.output_cost_per_mtok, Some(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should include llama
|
||||||
|
assert!(models.iter().any(|m| m.id.contains("llama")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ollama_message_serialization() {
|
||||||
|
let msg = OllamaMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: "Hello!".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert!(json.contains("\"role\":\"user\""));
|
||||||
|
assert!(json.contains("\"content\":\"Hello!\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chat_request_serialization() {
|
||||||
|
let request = OllamaChatRequest {
|
||||||
|
model: "llama3.2".to_string(),
|
||||||
|
messages: vec![OllamaMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: "Test".to_string(),
|
||||||
|
}],
|
||||||
|
stream: true,
|
||||||
|
system: Some("You are helpful".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).unwrap();
|
||||||
|
assert!(json.contains("\"model\":\"llama3.2\""));
|
||||||
|
assert!(json.contains("\"stream\":true"));
|
||||||
|
assert!(json.contains("\"system\":\"You are helpful\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chat_request_without_system() {
|
||||||
|
let request = OllamaChatRequest {
|
||||||
|
model: "llama3.2".to_string(),
|
||||||
|
messages: vec![],
|
||||||
|
stream: true,
|
||||||
|
system: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).unwrap();
|
||||||
|
// system should be omitted when None
|
||||||
|
assert!(!json.contains("\"system\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// These types are all used by the provider abstraction layer.
|
||||||
|
// They will be actively used once providers are fully integrated with BridgeManager.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
pub api_base_url: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub custom_instructions: Option<String>,
|
||||||
|
pub working_directory: String,
|
||||||
|
pub mcp_servers_json: Option<String>,
|
||||||
|
pub allowed_tools: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_options: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProviderConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
api_key: None,
|
||||||
|
api_base_url: None,
|
||||||
|
model: None,
|
||||||
|
custom_instructions: None,
|
||||||
|
working_directory: String::new(),
|
||||||
|
mcp_servers_json: None,
|
||||||
|
allowed_tools: Vec::new(),
|
||||||
|
extra_options: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProviderCapabilities {
|
||||||
|
pub supports_streaming: bool,
|
||||||
|
pub supports_tools: bool,
|
||||||
|
pub supports_vision: bool,
|
||||||
|
pub supports_thinking: bool,
|
||||||
|
pub supports_mcp: bool,
|
||||||
|
pub supports_resume_session: bool,
|
||||||
|
pub max_context_tokens: Option<u64>,
|
||||||
|
pub available_models: Vec<ModelInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub context_window: Option<u64>,
|
||||||
|
pub input_cost_per_mtok: Option<f64>,
|
||||||
|
pub output_cost_per_mtok: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum ProviderMessage {
|
||||||
|
#[serde(rename = "text")]
|
||||||
|
Text { content: String },
|
||||||
|
#[serde(rename = "tool_use")]
|
||||||
|
ToolUse {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: serde_json::Value,
|
||||||
|
},
|
||||||
|
#[serde(rename = "tool_result")]
|
||||||
|
ToolResult {
|
||||||
|
tool_use_id: String,
|
||||||
|
content: String,
|
||||||
|
is_error: bool,
|
||||||
|
},
|
||||||
|
#[serde(rename = "thinking")]
|
||||||
|
Thinking { content: String },
|
||||||
|
#[serde(rename = "image")]
|
||||||
|
Image { media_type: String, data: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProviderUsage {
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum ProviderStreamEvent {
|
||||||
|
#[serde(rename = "connected")]
|
||||||
|
Connected { session_id: Option<String> },
|
||||||
|
#[serde(rename = "text_delta")]
|
||||||
|
TextDelta { text: String },
|
||||||
|
#[serde(rename = "thinking_delta")]
|
||||||
|
ThinkingDelta { text: String },
|
||||||
|
#[serde(rename = "tool_use_start")]
|
||||||
|
ToolUseStart { id: String, name: String },
|
||||||
|
#[serde(rename = "tool_use_delta")]
|
||||||
|
ToolUseDelta { id: String, input_delta: String },
|
||||||
|
#[serde(rename = "tool_use_end")]
|
||||||
|
ToolUseEnd { id: String, input: serde_json::Value },
|
||||||
|
#[serde(rename = "message_complete")]
|
||||||
|
MessageComplete {
|
||||||
|
content: Vec<ProviderMessage>,
|
||||||
|
usage: Option<ProviderUsage>,
|
||||||
|
stop_reason: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "permission_request")]
|
||||||
|
PermissionRequest {
|
||||||
|
id: String,
|
||||||
|
tool_name: String,
|
||||||
|
tool_input: serde_json::Value,
|
||||||
|
description: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "question")]
|
||||||
|
Question {
|
||||||
|
id: String,
|
||||||
|
question: String,
|
||||||
|
header: Option<String>,
|
||||||
|
options: Vec<QuestionOption>,
|
||||||
|
multi_select: bool,
|
||||||
|
},
|
||||||
|
#[serde(rename = "working_directory")]
|
||||||
|
WorkingDirectory { path: String },
|
||||||
|
#[serde(rename = "error")]
|
||||||
|
Error { message: String },
|
||||||
|
#[serde(rename = "disconnected")]
|
||||||
|
Disconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QuestionOption {
|
||||||
|
pub label: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type StreamCallback = Box<dyn Fn(ProviderStreamEvent) + Send + Sync>;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait LlmProvider: Send + Sync {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
fn capabilities(&self) -> ProviderCapabilities;
|
||||||
|
|
||||||
|
async fn start(&mut self, callback: StreamCallback) -> Result<(), String>;
|
||||||
|
|
||||||
|
async fn stop(&mut self) -> Result<(), String>;
|
||||||
|
|
||||||
|
fn is_running(&self) -> bool;
|
||||||
|
|
||||||
|
async fn send_message(&mut self, message: &str) -> Result<(), String>;
|
||||||
|
|
||||||
|
async fn send_tool_result(
|
||||||
|
&mut self,
|
||||||
|
tool_use_id: &str,
|
||||||
|
result: serde_json::Value,
|
||||||
|
is_error: bool,
|
||||||
|
) -> Result<(), String>;
|
||||||
|
|
||||||
|
fn get_model(&self) -> Option<String>;
|
||||||
|
|
||||||
|
fn get_working_directory(&self) -> &str;
|
||||||
|
|
||||||
|
async fn interrupt(&mut self) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_config_default() {
|
||||||
|
let config = ProviderConfig::default();
|
||||||
|
assert!(config.api_key.is_none());
|
||||||
|
assert!(config.api_base_url.is_none());
|
||||||
|
assert!(config.model.is_none());
|
||||||
|
assert!(config.working_directory.is_empty());
|
||||||
|
assert!(config.allowed_tools.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_message_serialization() {
|
||||||
|
let msg = ProviderMessage::Text {
|
||||||
|
content: "Hello!".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert!(json.contains("\"type\":\"text\""));
|
||||||
|
assert!(json.contains("\"content\":\"Hello!\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_stream_event_serialization() {
|
||||||
|
let event = ProviderStreamEvent::TextDelta {
|
||||||
|
text: "chunk".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(json.contains("\"type\":\"text_delta\""));
|
||||||
|
|
||||||
|
let event = ProviderStreamEvent::Connected {
|
||||||
|
session_id: Some("test-123".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(json.contains("\"session_id\":\"test-123\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_model_info() {
|
||||||
|
let model = ModelInfo {
|
||||||
|
id: "claude-sonnet-4-20250514".to_string(),
|
||||||
|
name: "Claude Sonnet 4".to_string(),
|
||||||
|
description: Some("Fast and intelligent".to_string()),
|
||||||
|
context_window: Some(200000),
|
||||||
|
input_cost_per_mtok: Some(3.0),
|
||||||
|
output_cost_per_mtok: Some(15.0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&model).unwrap();
|
||||||
|
assert!(json.contains("claude-sonnet-4"));
|
||||||
|
assert!(json.contains("200000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_usage() {
|
||||||
|
let usage = ProviderUsage {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 50,
|
||||||
|
model: "claude-opus-4-20250514".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&usage).unwrap();
|
||||||
|
assert!(json.contains("\"input_tokens\":100"));
|
||||||
|
assert!(json.contains("\"output_tokens\":50"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
const QUICK_ACTIONS_STORE_KEY: &str = "quick_actions";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QuickAction {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub prompt: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub is_default: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_default_quick_actions() -> Vec<QuickAction> {
|
||||||
|
let now = Utc::now();
|
||||||
|
vec![
|
||||||
|
QuickAction {
|
||||||
|
id: "default-review-pr".to_string(),
|
||||||
|
name: "Review PR".to_string(),
|
||||||
|
prompt: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
|
||||||
|
icon: "git-pull-request".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
QuickAction {
|
||||||
|
id: "default-run-tests".to_string(),
|
||||||
|
name: "Run Tests".to_string(),
|
||||||
|
prompt: "Please run the test suite for this project and report any failures or issues.".to_string(),
|
||||||
|
icon: "play".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
QuickAction {
|
||||||
|
id: "default-explain-file".to_string(),
|
||||||
|
name: "Explain File".to_string(),
|
||||||
|
prompt: "Please explain what this file does, its purpose, and how it fits into the overall project structure.".to_string(),
|
||||||
|
icon: "file-text".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
QuickAction {
|
||||||
|
id: "default-fix-error".to_string(),
|
||||||
|
name: "Fix Error".to_string(),
|
||||||
|
prompt: "I'm getting an error. Can you help me identify the cause and fix it?".to_string(),
|
||||||
|
icon: "alert-circle".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
QuickAction {
|
||||||
|
id: "default-write-tests".to_string(),
|
||||||
|
name: "Write Tests".to_string(),
|
||||||
|
prompt: "Please write comprehensive unit tests for the current code with good coverage.".to_string(),
|
||||||
|
icon: "check-square".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
QuickAction {
|
||||||
|
id: "default-refactor".to_string(),
|
||||||
|
name: "Refactor".to_string(),
|
||||||
|
prompt: "Please refactor this code to improve readability, maintainability, and performance.".to_string(),
|
||||||
|
icon: "refresh-cw".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_all_quick_actions(app: &AppHandle) -> Result<Vec<QuickAction>, String> {
|
||||||
|
let store = app
|
||||||
|
.store("hikari-quick-actions.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match store.get(QUICK_ACTIONS_STORE_KEY) {
|
||||||
|
Some(value) => {
|
||||||
|
let mut actions: Vec<QuickAction> =
|
||||||
|
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let defaults = get_default_quick_actions();
|
||||||
|
for default in defaults {
|
||||||
|
if !actions.iter().any(|a| a.id == default.id) {
|
||||||
|
actions.push(default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(actions)
|
||||||
|
}
|
||||||
|
None => Ok(get_default_quick_actions()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_all_quick_actions(app: &AppHandle, actions: &[QuickAction]) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store("hikari-quick-actions.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let value = serde_json::to_value(actions).map_err(|e| e.to_string())?;
|
||||||
|
store.set(QUICK_ACTIONS_STORE_KEY, value);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_quick_actions(app: AppHandle) -> Result<Vec<QuickAction>, String> {
|
||||||
|
let mut actions = load_all_quick_actions(&app)?;
|
||||||
|
|
||||||
|
actions.sort_by(|a, b| {
|
||||||
|
let default_cmp = b.is_default.cmp(&a.is_default);
|
||||||
|
if default_cmp == std::cmp::Ordering::Equal {
|
||||||
|
a.name.cmp(&b.name)
|
||||||
|
} else {
|
||||||
|
default_cmp
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_quick_action(app: AppHandle, action: QuickAction) -> Result<(), String> {
|
||||||
|
let mut actions = load_all_quick_actions(&app)?;
|
||||||
|
|
||||||
|
if let Some(existing) = actions.iter_mut().find(|a| a.id == action.id) {
|
||||||
|
let mut updated = action;
|
||||||
|
updated.is_default = existing.is_default;
|
||||||
|
*existing = updated;
|
||||||
|
} else {
|
||||||
|
actions.push(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
save_all_quick_actions(&app, &actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_quick_action(app: AppHandle, action_id: String) -> Result<(), String> {
|
||||||
|
let mut actions = load_all_quick_actions(&app)?;
|
||||||
|
|
||||||
|
if actions
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.id == action_id && a.is_default)
|
||||||
|
{
|
||||||
|
return Err("Cannot delete default quick actions".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.retain(|a| a.id != action_id);
|
||||||
|
save_all_quick_actions(&app, &actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> {
|
||||||
|
let mut actions = load_all_quick_actions(&app)?;
|
||||||
|
|
||||||
|
actions.retain(|a| !a.is_default);
|
||||||
|
actions.extend(get_default_quick_actions());
|
||||||
|
|
||||||
|
save_all_quick_actions(&app, &actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn create_test_action(id: &str, name: &str, is_default: bool) -> QuickAction {
|
||||||
|
QuickAction {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
prompt: "Test prompt".to_string(),
|
||||||
|
icon: "star".to_string(),
|
||||||
|
is_default,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_quick_actions_exist() {
|
||||||
|
let defaults = get_default_quick_actions();
|
||||||
|
assert!(!defaults.is_empty());
|
||||||
|
assert!(defaults.iter().all(|a| a.is_default));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_quick_actions_have_required_fields() {
|
||||||
|
let defaults = get_default_quick_actions();
|
||||||
|
for action in defaults {
|
||||||
|
assert!(!action.id.is_empty());
|
||||||
|
assert!(!action.name.is_empty());
|
||||||
|
assert!(!action.prompt.is_empty());
|
||||||
|
assert!(!action.icon.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_quick_actions_count() {
|
||||||
|
let defaults = get_default_quick_actions();
|
||||||
|
// Should have 6 default actions
|
||||||
|
assert_eq!(defaults.len(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_quick_actions_have_unique_ids() {
|
||||||
|
let defaults = get_default_quick_actions();
|
||||||
|
let mut ids: Vec<&String> = defaults.iter().map(|a| &a.id).collect();
|
||||||
|
ids.sort();
|
||||||
|
ids.dedup();
|
||||||
|
assert_eq!(ids.len(), defaults.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_quick_actions_ids_start_with_default() {
|
||||||
|
let defaults = get_default_quick_actions();
|
||||||
|
assert!(defaults.iter().all(|a| a.id.starts_with("default-")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quick_action_serialization() {
|
||||||
|
let action = create_test_action("test-1", "Test Action", false);
|
||||||
|
let json = serde_json::to_string(&action).expect("Failed to serialize");
|
||||||
|
let parsed: QuickAction = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(parsed.id, action.id);
|
||||||
|
assert_eq!(parsed.name, action.name);
|
||||||
|
assert_eq!(parsed.prompt, action.prompt);
|
||||||
|
assert_eq!(parsed.icon, action.icon);
|
||||||
|
assert_eq!(parsed.is_default, action.is_default);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quick_action_clone() {
|
||||||
|
let original = create_test_action("clone-test", "Clone Test", true);
|
||||||
|
let cloned = original.clone();
|
||||||
|
|
||||||
|
assert_eq!(original.id, cloned.id);
|
||||||
|
assert_eq!(original.name, cloned.name);
|
||||||
|
assert_eq!(original.is_default, cloned.is_default);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::useless_vec)]
|
||||||
|
fn test_quick_action_sorting_defaults_first() {
|
||||||
|
let mut actions = vec![
|
||||||
|
create_test_action("custom-z", "Zebra", false),
|
||||||
|
create_test_action("default-a", "Apple", true),
|
||||||
|
create_test_action("custom-a", "Alpha", false),
|
||||||
|
create_test_action("default-z", "Zulu", true),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sort by: defaults first, then alphabetically by name
|
||||||
|
actions.sort_by(|a, b| {
|
||||||
|
let default_cmp = b.is_default.cmp(&a.is_default);
|
||||||
|
if default_cmp == std::cmp::Ordering::Equal {
|
||||||
|
a.name.cmp(&b.name)
|
||||||
|
} else {
|
||||||
|
default_cmp
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defaults should come first
|
||||||
|
assert!(actions[0].is_default);
|
||||||
|
assert!(actions[1].is_default);
|
||||||
|
assert!(!actions[2].is_default);
|
||||||
|
assert!(!actions[3].is_default);
|
||||||
|
|
||||||
|
// Within defaults, alphabetically sorted
|
||||||
|
assert_eq!(actions[0].name, "Apple");
|
||||||
|
assert_eq!(actions[1].name, "Zulu");
|
||||||
|
|
||||||
|
// Within non-defaults, alphabetically sorted
|
||||||
|
assert_eq!(actions[2].name, "Alpha");
|
||||||
|
assert_eq!(actions[3].name, "Zebra");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_known_default_actions() {
|
||||||
|
let defaults = get_default_quick_actions();
|
||||||
|
let ids: Vec<&str> = defaults.iter().map(|a| a.id.as_str()).collect();
|
||||||
|
|
||||||
|
assert!(ids.contains(&"default-review-pr"));
|
||||||
|
assert!(ids.contains(&"default-run-tests"));
|
||||||
|
assert!(ids.contains(&"default-explain-file"));
|
||||||
|
assert!(ids.contains(&"default-fix-error"));
|
||||||
|
assert!(ids.contains(&"default-write-tests"));
|
||||||
|
assert!(ids.contains(&"default-refactor"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_action_icons() {
|
||||||
|
let defaults = get_default_quick_actions();
|
||||||
|
let icons: Vec<&str> = defaults.iter().map(|a| a.icon.as_str()).collect();
|
||||||
|
|
||||||
|
assert!(icons.contains(&"git-pull-request"));
|
||||||
|
assert!(icons.contains(&"play"));
|
||||||
|
assert!(icons.contains(&"file-text"));
|
||||||
|
assert!(icons.contains(&"alert-circle"));
|
||||||
|
assert!(icons.contains(&"check-square"));
|
||||||
|
assert!(icons.contains(&"refresh-cw"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quick_action_prompts_not_empty() {
|
||||||
|
let defaults = get_default_quick_actions();
|
||||||
|
for action in defaults {
|
||||||
|
assert!(
|
||||||
|
action.prompt.len() > 10,
|
||||||
|
"Prompt should be meaningful: {}",
|
||||||
|
action.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quick_action_timestamps() {
|
||||||
|
let action = create_test_action("time-test", "Time Test", false);
|
||||||
|
assert!(action.created_at <= action.updated_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_actions_have_same_timestamps() {
|
||||||
|
let defaults = get_default_quick_actions();
|
||||||
|
// All defaults are created at the same instant
|
||||||
|
let first_created = defaults[0].created_at;
|
||||||
|
let first_updated = defaults[0].updated_at;
|
||||||
|
|
||||||
|
for action in &defaults {
|
||||||
|
assert_eq!(action.created_at, first_created);
|
||||||
|
assert_eq!(action.updated_at, first_updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_retain_non_default() {
|
||||||
|
let mut actions = vec![
|
||||||
|
create_test_action("default-1", "Default 1", true),
|
||||||
|
create_test_action("custom-1", "Custom 1", false),
|
||||||
|
create_test_action("default-2", "Default 2", true),
|
||||||
|
create_test_action("custom-2", "Custom 2", false),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mimics reset_default_quick_actions behavior (retain non-defaults)
|
||||||
|
actions.retain(|a| !a.is_default);
|
||||||
|
|
||||||
|
assert_eq!(actions.len(), 2);
|
||||||
|
assert!(actions.iter().all(|a| !a.is_default));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::useless_vec)]
|
||||||
|
fn test_action_find_by_id() {
|
||||||
|
let actions = vec![
|
||||||
|
create_test_action("action-1", "First", false),
|
||||||
|
create_test_action("action-2", "Second", false),
|
||||||
|
create_test_action("action-3", "Third", false),
|
||||||
|
];
|
||||||
|
|
||||||
|
let found = actions.iter().find(|a| a.id == "action-2");
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().name, "Second");
|
||||||
|
|
||||||
|
let not_found = actions.iter().find(|a| a.id == "action-999");
|
||||||
|
assert!(not_found.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
const SESSIONS_STORE_KEY: &str = "sessions";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SavedSession {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_activity_at: DateTime<Utc>,
|
||||||
|
pub working_directory: String,
|
||||||
|
pub message_count: usize,
|
||||||
|
pub preview: String, // First ~100 chars of conversation for preview
|
||||||
|
pub messages: Vec<SavedMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SavedMessage {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub message_type: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub tool_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionListItem {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_activity_at: DateTime<Utc>,
|
||||||
|
pub working_directory: String,
|
||||||
|
pub message_count: usize,
|
||||||
|
pub preview: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&SavedSession> for SessionListItem {
|
||||||
|
fn from(session: &SavedSession) -> Self {
|
||||||
|
SessionListItem {
|
||||||
|
id: session.id.clone(),
|
||||||
|
name: session.name.clone(),
|
||||||
|
created_at: session.created_at,
|
||||||
|
last_activity_at: session.last_activity_at,
|
||||||
|
working_directory: session.working_directory.clone(),
|
||||||
|
message_count: session.message_count,
|
||||||
|
preview: session.preview.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_all_sessions(app: &AppHandle) -> Result<Vec<SavedSession>, String> {
|
||||||
|
let store = app
|
||||||
|
.store("hikari-sessions.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match store.get(SESSIONS_STORE_KEY) {
|
||||||
|
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_all_sessions(app: &AppHandle, sessions: &[SavedSession]) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store("hikari-sessions.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let value = serde_json::to_value(sessions).map_err(|e| e.to_string())?;
|
||||||
|
store.set(SESSIONS_STORE_KEY, value);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_sessions(app: AppHandle) -> Result<Vec<SessionListItem>, String> {
|
||||||
|
let sessions = load_all_sessions(&app)?;
|
||||||
|
let mut items: Vec<SessionListItem> = sessions.iter().map(SessionListItem::from).collect();
|
||||||
|
|
||||||
|
// Sort by last activity, most recent first
|
||||||
|
items.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_session(app: AppHandle, session: SavedSession) -> Result<(), String> {
|
||||||
|
let mut sessions = load_all_sessions(&app)?;
|
||||||
|
|
||||||
|
// Update existing or add new
|
||||||
|
if let Some(existing) = sessions.iter_mut().find(|s| s.id == session.id) {
|
||||||
|
*existing = session;
|
||||||
|
} else {
|
||||||
|
sessions.push(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
save_all_sessions(&app, &sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn load_session(app: AppHandle, session_id: String) -> Result<Option<SavedSession>, String> {
|
||||||
|
let sessions = load_all_sessions(&app)?;
|
||||||
|
Ok(sessions.into_iter().find(|s| s.id == session_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_session(app: AppHandle, session_id: String) -> Result<(), String> {
|
||||||
|
let mut sessions = load_all_sessions(&app)?;
|
||||||
|
sessions.retain(|s| s.id != session_id);
|
||||||
|
save_all_sessions(&app, &sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_sessions(app: AppHandle, query: String) -> Result<Vec<SessionListItem>, String> {
|
||||||
|
let sessions = load_all_sessions(&app)?;
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
|
||||||
|
let mut matching: Vec<SessionListItem> = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
s.name.to_lowercase().contains(&query_lower)
|
||||||
|
|| s.preview.to_lowercase().contains(&query_lower)
|
||||||
|
|| s.working_directory.to_lowercase().contains(&query_lower)
|
||||||
|
|| s.messages
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.content.to_lowercase().contains(&query_lower))
|
||||||
|
})
|
||||||
|
.map(SessionListItem::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by last activity, most recent first
|
||||||
|
matching.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
|
||||||
|
|
||||||
|
Ok(matching)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> {
|
||||||
|
save_all_sessions(&app, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
fn create_test_session(id: &str, name: &str) -> SavedSession {
|
||||||
|
SavedSession {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
working_directory: "/home/test".to_string(),
|
||||||
|
message_count: 5,
|
||||||
|
preview: "Hello world".to_string(),
|
||||||
|
messages: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_test_message(id: &str, content: &str, msg_type: &str) -> SavedMessage {
|
||||||
|
SavedMessage {
|
||||||
|
id: id.to_string(),
|
||||||
|
message_type: msg_type.to_string(),
|
||||||
|
content: content.to_string(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
tool_name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_list_item_from_saved_session() {
|
||||||
|
let session = SavedSession {
|
||||||
|
id: "test-id".to_string(),
|
||||||
|
name: "Test Session".to_string(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
working_directory: "/home/test".to_string(),
|
||||||
|
message_count: 5,
|
||||||
|
preview: "Hello world".to_string(),
|
||||||
|
messages: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = SessionListItem::from(&session);
|
||||||
|
assert_eq!(item.id, "test-id");
|
||||||
|
assert_eq!(item.name, "Test Session");
|
||||||
|
assert_eq!(item.message_count, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_list_item_preserves_all_fields() {
|
||||||
|
let created = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();
|
||||||
|
let last_activity = Utc.with_ymd_and_hms(2024, 1, 15, 14, 45, 0).unwrap();
|
||||||
|
|
||||||
|
let session = SavedSession {
|
||||||
|
id: "sess-123".to_string(),
|
||||||
|
name: "My Chat".to_string(),
|
||||||
|
created_at: created,
|
||||||
|
last_activity_at: last_activity,
|
||||||
|
working_directory: "/home/naomi/project".to_string(),
|
||||||
|
message_count: 42,
|
||||||
|
preview: "What is the meaning of life?".to_string(),
|
||||||
|
messages: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = SessionListItem::from(&session);
|
||||||
|
|
||||||
|
assert_eq!(item.id, "sess-123");
|
||||||
|
assert_eq!(item.name, "My Chat");
|
||||||
|
assert_eq!(item.created_at, created);
|
||||||
|
assert_eq!(item.last_activity_at, last_activity);
|
||||||
|
assert_eq!(item.working_directory, "/home/naomi/project");
|
||||||
|
assert_eq!(item.message_count, 42);
|
||||||
|
assert_eq!(item.preview, "What is the meaning of life?");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_saved_session_serialization() {
|
||||||
|
let session = create_test_session("test-1", "Test Session");
|
||||||
|
let json = serde_json::to_string(&session).expect("Failed to serialize");
|
||||||
|
let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(parsed.id, session.id);
|
||||||
|
assert_eq!(parsed.name, session.name);
|
||||||
|
assert_eq!(parsed.working_directory, session.working_directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_saved_message_serialization() {
|
||||||
|
let message = create_test_message("msg-1", "Hello!", "user");
|
||||||
|
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
||||||
|
let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(parsed.id, message.id);
|
||||||
|
assert_eq!(parsed.content, message.content);
|
||||||
|
assert_eq!(parsed.message_type, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_saved_message_with_tool_name() {
|
||||||
|
let message = SavedMessage {
|
||||||
|
id: "msg-tool-1".to_string(),
|
||||||
|
message_type: "tool".to_string(),
|
||||||
|
content: "File read successfully".to_string(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
tool_name: Some("Read".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
||||||
|
let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(parsed.tool_name, Some("Read".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_with_messages_serialization() {
|
||||||
|
let mut session = create_test_session("sess-full", "Full Session");
|
||||||
|
session.messages = vec![
|
||||||
|
create_test_message("msg-1", "Hello!", "user"),
|
||||||
|
create_test_message("msg-2", "Hi there!", "assistant"),
|
||||||
|
create_test_message("msg-3", "Read file", "tool"),
|
||||||
|
];
|
||||||
|
session.message_count = 3;
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&session).expect("Failed to serialize");
|
||||||
|
let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(parsed.messages.len(), 3);
|
||||||
|
assert_eq!(parsed.messages[0].content, "Hello!");
|
||||||
|
assert_eq!(parsed.messages[1].message_type, "assistant");
|
||||||
|
assert_eq!(parsed.messages[2].message_type, "tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_list_item_serialization() {
|
||||||
|
let item = SessionListItem {
|
||||||
|
id: "list-item-1".to_string(),
|
||||||
|
name: "Quick Chat".to_string(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
working_directory: "/tmp".to_string(),
|
||||||
|
message_count: 10,
|
||||||
|
preview: "Short preview...".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&item).expect("Failed to serialize");
|
||||||
|
let parsed: SessionListItem = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(parsed.id, item.id);
|
||||||
|
assert_eq!(parsed.name, item.name);
|
||||||
|
assert_eq!(parsed.preview, item.preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_type_field_rename() {
|
||||||
|
// The message_type field is renamed to "type" in JSON
|
||||||
|
let message = create_test_message("msg-1", "Test", "assistant");
|
||||||
|
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
||||||
|
|
||||||
|
assert!(json.contains("\"type\":"));
|
||||||
|
assert!(!json.contains("\"message_type\":"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_default_empty_messages() {
|
||||||
|
let session = SavedSession {
|
||||||
|
id: "empty".to_string(),
|
||||||
|
name: "Empty".to_string(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
working_directory: "/".to_string(),
|
||||||
|
message_count: 0,
|
||||||
|
preview: "".to_string(),
|
||||||
|
messages: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(session.messages.is_empty());
|
||||||
|
assert_eq!(session.message_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::useless_vec)]
|
||||||
|
fn test_session_sorting_by_activity() {
|
||||||
|
let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||||
|
let new_time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
|
||||||
|
|
||||||
|
let mut sessions = vec![
|
||||||
|
SessionListItem {
|
||||||
|
id: "old".to_string(),
|
||||||
|
name: "Old Session".to_string(),
|
||||||
|
created_at: old_time,
|
||||||
|
last_activity_at: old_time,
|
||||||
|
working_directory: "/old".to_string(),
|
||||||
|
message_count: 1,
|
||||||
|
preview: "Old".to_string(),
|
||||||
|
},
|
||||||
|
SessionListItem {
|
||||||
|
id: "new".to_string(),
|
||||||
|
name: "New Session".to_string(),
|
||||||
|
created_at: new_time,
|
||||||
|
last_activity_at: new_time,
|
||||||
|
working_directory: "/new".to_string(),
|
||||||
|
message_count: 1,
|
||||||
|
preview: "New".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sort by last activity, most recent first (mimics list_sessions behavior)
|
||||||
|
sessions.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
|
||||||
|
|
||||||
|
assert_eq!(sessions[0].id, "new");
|
||||||
|
assert_eq!(sessions[1].id, "old");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_clone() {
|
||||||
|
let original = create_test_session("clone-test", "Clone Test");
|
||||||
|
let cloned = original.clone();
|
||||||
|
|
||||||
|
assert_eq!(original.id, cloned.id);
|
||||||
|
assert_eq!(original.name, cloned.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_clone() {
|
||||||
|
let original = create_test_message("msg-clone", "Content", "user");
|
||||||
|
let cloned = original.clone();
|
||||||
|
|
||||||
|
assert_eq!(original.id, cloned.id);
|
||||||
|
assert_eq!(original.content, cloned.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
const SNIPPETS_STORE_KEY: &str = "snippets";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Snippet {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub content: String,
|
||||||
|
pub category: String,
|
||||||
|
pub is_default: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_default_snippets() -> Vec<Snippet> {
|
||||||
|
let now = Utc::now();
|
||||||
|
vec![
|
||||||
|
Snippet {
|
||||||
|
id: "default-explain-code".to_string(),
|
||||||
|
name: "Explain this code".to_string(),
|
||||||
|
content: "Please explain what this code does, step by step:".to_string(),
|
||||||
|
category: "Code Review".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
Snippet {
|
||||||
|
id: "default-fix-error".to_string(),
|
||||||
|
name: "Fix this error".to_string(),
|
||||||
|
content: "I'm getting the following error. Can you help me fix it?".to_string(),
|
||||||
|
category: "Debugging".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
Snippet {
|
||||||
|
id: "default-write-tests".to_string(),
|
||||||
|
name: "Write tests".to_string(),
|
||||||
|
content: "Please write unit tests for this code with good coverage:".to_string(),
|
||||||
|
category: "Testing".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
Snippet {
|
||||||
|
id: "default-refactor".to_string(),
|
||||||
|
name: "Refactor for clarity".to_string(),
|
||||||
|
content: "Please refactor this code to improve readability and maintainability:".to_string(),
|
||||||
|
category: "Code Review".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
Snippet {
|
||||||
|
id: "default-optimize".to_string(),
|
||||||
|
name: "Optimize performance".to_string(),
|
||||||
|
content: "Please analyze this code for performance issues and suggest optimizations:".to_string(),
|
||||||
|
category: "Performance".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
Snippet {
|
||||||
|
id: "default-review-pr".to_string(),
|
||||||
|
name: "Review PR".to_string(),
|
||||||
|
content: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
|
||||||
|
category: "Code Review".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
Snippet {
|
||||||
|
id: "default-add-comments".to_string(),
|
||||||
|
name: "Add documentation".to_string(),
|
||||||
|
content: "Please add clear documentation comments to this code explaining what it does:".to_string(),
|
||||||
|
category: "Documentation".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
Snippet {
|
||||||
|
id: "default-security-review".to_string(),
|
||||||
|
name: "Security review".to_string(),
|
||||||
|
content: "Please review this code for security vulnerabilities and suggest fixes:".to_string(),
|
||||||
|
category: "Security".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_all_snippets(app: &AppHandle) -> Result<Vec<Snippet>, String> {
|
||||||
|
let store = app
|
||||||
|
.store("hikari-snippets.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match store.get(SNIPPETS_STORE_KEY) {
|
||||||
|
Some(value) => {
|
||||||
|
let mut snippets: Vec<Snippet> =
|
||||||
|
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Ensure default snippets exist (in case new ones were added in an update)
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
for default in defaults {
|
||||||
|
if !snippets.iter().any(|s| s.id == default.id) {
|
||||||
|
snippets.push(default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(snippets)
|
||||||
|
}
|
||||||
|
None => Ok(get_default_snippets()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_all_snippets(app: &AppHandle, snippets: &[Snippet]) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store("hikari-snippets.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let value = serde_json::to_value(snippets).map_err(|e| e.to_string())?;
|
||||||
|
store.set(SNIPPETS_STORE_KEY, value);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_snippets(app: AppHandle) -> Result<Vec<Snippet>, String> {
|
||||||
|
let mut snippets = load_all_snippets(&app)?;
|
||||||
|
|
||||||
|
// Sort by category, then by name
|
||||||
|
snippets.sort_by(|a, b| {
|
||||||
|
let cat_cmp = a.category.cmp(&b.category);
|
||||||
|
if cat_cmp == std::cmp::Ordering::Equal {
|
||||||
|
a.name.cmp(&b.name)
|
||||||
|
} else {
|
||||||
|
cat_cmp
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(snippets)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_snippet(app: AppHandle, snippet: Snippet) -> Result<(), String> {
|
||||||
|
let mut snippets = load_all_snippets(&app)?;
|
||||||
|
|
||||||
|
// Update existing or add new
|
||||||
|
if let Some(existing) = snippets.iter_mut().find(|s| s.id == snippet.id) {
|
||||||
|
// Don't allow editing default snippets' is_default flag
|
||||||
|
let mut updated = snippet;
|
||||||
|
updated.is_default = existing.is_default;
|
||||||
|
*existing = updated;
|
||||||
|
} else {
|
||||||
|
snippets.push(snippet);
|
||||||
|
}
|
||||||
|
|
||||||
|
save_all_snippets(&app, &snippets)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_snippet(app: AppHandle, snippet_id: String) -> Result<(), String> {
|
||||||
|
let mut snippets = load_all_snippets(&app)?;
|
||||||
|
|
||||||
|
// Don't allow deleting default snippets
|
||||||
|
if snippets
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.id == snippet_id && s.is_default)
|
||||||
|
{
|
||||||
|
return Err("Cannot delete default snippets".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
snippets.retain(|s| s.id != snippet_id);
|
||||||
|
save_all_snippets(&app, &snippets)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_snippet_categories(app: AppHandle) -> Result<Vec<String>, String> {
|
||||||
|
let snippets = load_all_snippets(&app)?;
|
||||||
|
let mut categories: Vec<String> = snippets.iter().map(|s| s.category.clone()).collect();
|
||||||
|
categories.sort();
|
||||||
|
categories.dedup();
|
||||||
|
Ok(categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reset_default_snippets(app: AppHandle) -> Result<(), String> {
|
||||||
|
let mut snippets = load_all_snippets(&app)?;
|
||||||
|
|
||||||
|
// Remove all default snippets
|
||||||
|
snippets.retain(|s| !s.is_default);
|
||||||
|
|
||||||
|
// Add fresh default snippets
|
||||||
|
snippets.extend(get_default_snippets());
|
||||||
|
|
||||||
|
save_all_snippets(&app, &snippets)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
fn create_test_snippet(id: &str, name: &str, category: &str, is_default: bool) -> Snippet {
|
||||||
|
Snippet {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
content: "Test content".to_string(),
|
||||||
|
category: category.to_string(),
|
||||||
|
is_default,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_snippets_exist() {
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
assert!(!defaults.is_empty());
|
||||||
|
assert!(defaults.iter().all(|s| s.is_default));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_snippets_have_required_fields() {
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
for snippet in defaults {
|
||||||
|
assert!(!snippet.id.is_empty());
|
||||||
|
assert!(!snippet.name.is_empty());
|
||||||
|
assert!(!snippet.content.is_empty());
|
||||||
|
assert!(!snippet.category.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_snippets_count() {
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
// Should have 8 default snippets
|
||||||
|
assert_eq!(defaults.len(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_snippets_have_unique_ids() {
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
let ids: HashSet<&String> = defaults.iter().map(|s| &s.id).collect();
|
||||||
|
assert_eq!(ids.len(), defaults.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_snippets_ids_start_with_default() {
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
assert!(defaults.iter().all(|s| s.id.starts_with("default-")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snippet_serialization() {
|
||||||
|
let snippet = create_test_snippet("test-1", "Test Snippet", "Testing", false);
|
||||||
|
let json = serde_json::to_string(&snippet).expect("Failed to serialize");
|
||||||
|
let parsed: Snippet = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(parsed.id, snippet.id);
|
||||||
|
assert_eq!(parsed.name, snippet.name);
|
||||||
|
assert_eq!(parsed.content, snippet.content);
|
||||||
|
assert_eq!(parsed.category, snippet.category);
|
||||||
|
assert_eq!(parsed.is_default, snippet.is_default);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snippet_clone() {
|
||||||
|
let original = create_test_snippet("clone-test", "Clone Test", "Category", true);
|
||||||
|
let cloned = original.clone();
|
||||||
|
|
||||||
|
assert_eq!(original.id, cloned.id);
|
||||||
|
assert_eq!(original.name, cloned.name);
|
||||||
|
assert_eq!(original.is_default, cloned.is_default);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::useless_vec)]
|
||||||
|
fn test_snippet_sorting_by_category_then_name() {
|
||||||
|
let mut snippets = vec![
|
||||||
|
create_test_snippet("s1", "Zebra", "B-Category", false),
|
||||||
|
create_test_snippet("s2", "Apple", "A-Category", false),
|
||||||
|
create_test_snippet("s3", "Banana", "B-Category", false),
|
||||||
|
create_test_snippet("s4", "Alpha", "A-Category", false),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sort by category, then by name (mimics list_snippets behavior)
|
||||||
|
snippets.sort_by(|a, b| {
|
||||||
|
let cat_cmp = a.category.cmp(&b.category);
|
||||||
|
if cat_cmp == std::cmp::Ordering::Equal {
|
||||||
|
a.name.cmp(&b.name)
|
||||||
|
} else {
|
||||||
|
cat_cmp
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A-Category should come first
|
||||||
|
assert_eq!(snippets[0].category, "A-Category");
|
||||||
|
assert_eq!(snippets[1].category, "A-Category");
|
||||||
|
assert_eq!(snippets[2].category, "B-Category");
|
||||||
|
assert_eq!(snippets[3].category, "B-Category");
|
||||||
|
|
||||||
|
// Within categories, alphabetically by name
|
||||||
|
assert_eq!(snippets[0].name, "Alpha");
|
||||||
|
assert_eq!(snippets[1].name, "Apple");
|
||||||
|
assert_eq!(snippets[2].name, "Banana");
|
||||||
|
assert_eq!(snippets[3].name, "Zebra");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_known_default_snippets() {
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
let ids: Vec<&str> = defaults.iter().map(|s| s.id.as_str()).collect();
|
||||||
|
|
||||||
|
assert!(ids.contains(&"default-explain-code"));
|
||||||
|
assert!(ids.contains(&"default-fix-error"));
|
||||||
|
assert!(ids.contains(&"default-write-tests"));
|
||||||
|
assert!(ids.contains(&"default-refactor"));
|
||||||
|
assert!(ids.contains(&"default-optimize"));
|
||||||
|
assert!(ids.contains(&"default-review-pr"));
|
||||||
|
assert!(ids.contains(&"default-add-comments"));
|
||||||
|
assert!(ids.contains(&"default-security-review"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_snippet_categories() {
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
let categories: HashSet<&String> = defaults.iter().map(|s| &s.category).collect();
|
||||||
|
|
||||||
|
assert!(categories.contains(&"Code Review".to_string()));
|
||||||
|
assert!(categories.contains(&"Debugging".to_string()));
|
||||||
|
assert!(categories.contains(&"Testing".to_string()));
|
||||||
|
assert!(categories.contains(&"Performance".to_string()));
|
||||||
|
assert!(categories.contains(&"Documentation".to_string()));
|
||||||
|
assert!(categories.contains(&"Security".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snippet_content_not_empty() {
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
for snippet in defaults {
|
||||||
|
assert!(
|
||||||
|
snippet.content.len() > 10,
|
||||||
|
"Content should be meaningful: {}",
|
||||||
|
snippet.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snippet_timestamps() {
|
||||||
|
let snippet = create_test_snippet("time-test", "Time Test", "Cat", false);
|
||||||
|
assert!(snippet.created_at <= snippet.updated_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_snippets_have_same_timestamps() {
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
// All defaults are created at the same instant
|
||||||
|
let first_created = defaults[0].created_at;
|
||||||
|
let first_updated = defaults[0].updated_at;
|
||||||
|
|
||||||
|
for snippet in &defaults {
|
||||||
|
assert_eq!(snippet.created_at, first_created);
|
||||||
|
assert_eq!(snippet.updated_at, first_updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snippet_retain_non_default() {
|
||||||
|
let mut snippets = vec![
|
||||||
|
create_test_snippet("default-1", "Default 1", "Cat", true),
|
||||||
|
create_test_snippet("custom-1", "Custom 1", "Cat", false),
|
||||||
|
create_test_snippet("default-2", "Default 2", "Cat", true),
|
||||||
|
create_test_snippet("custom-2", "Custom 2", "Cat", false),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mimics reset_default_snippets behavior (retain non-defaults)
|
||||||
|
snippets.retain(|s| !s.is_default);
|
||||||
|
|
||||||
|
assert_eq!(snippets.len(), 2);
|
||||||
|
assert!(snippets.iter().all(|s| !s.is_default));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::useless_vec)]
|
||||||
|
fn test_snippet_find_by_id() {
|
||||||
|
let snippets = vec![
|
||||||
|
create_test_snippet("snippet-1", "First", "Cat", false),
|
||||||
|
create_test_snippet("snippet-2", "Second", "Cat", false),
|
||||||
|
create_test_snippet("snippet-3", "Third", "Cat", false),
|
||||||
|
];
|
||||||
|
|
||||||
|
let found = snippets.iter().find(|s| s.id == "snippet-2");
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().name, "Second");
|
||||||
|
|
||||||
|
let not_found = snippets.iter().find(|s| s.id == "snippet-999");
|
||||||
|
assert!(not_found.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::useless_vec)]
|
||||||
|
fn test_extract_categories_sorted_and_deduped() {
|
||||||
|
let snippets = vec![
|
||||||
|
create_test_snippet("s1", "S1", "Zebra", false),
|
||||||
|
create_test_snippet("s2", "S2", "Alpha", false),
|
||||||
|
create_test_snippet("s3", "S3", "Beta", false),
|
||||||
|
create_test_snippet("s4", "S4", "Alpha", false), // Duplicate
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut categories: Vec<String> = snippets.iter().map(|s| s.category.clone()).collect();
|
||||||
|
categories.sort();
|
||||||
|
categories.dedup();
|
||||||
|
|
||||||
|
assert_eq!(categories.len(), 3);
|
||||||
|
assert_eq!(categories[0], "Alpha");
|
||||||
|
assert_eq!(categories[1], "Beta");
|
||||||
|
assert_eq!(categories[2], "Zebra");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snippet_category_code_review_count() {
|
||||||
|
let defaults = get_default_snippets();
|
||||||
|
let code_review_count = defaults
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.category == "Code Review")
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// There should be multiple code review snippets
|
||||||
|
assert!(code_review_count >= 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
use crate::achievements::{check_achievements, AchievementProgress};
|
||||||
|
use chrono::{Local, Timelike};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct UsageStats {
|
||||||
|
pub total_input_tokens: u64,
|
||||||
|
pub total_output_tokens: u64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub session_input_tokens: u64,
|
||||||
|
pub session_output_tokens: u64,
|
||||||
|
pub session_cost_usd: f64,
|
||||||
|
pub model: Option<String>,
|
||||||
|
|
||||||
|
// New fields
|
||||||
|
pub messages_exchanged: u64,
|
||||||
|
pub session_messages_exchanged: u64,
|
||||||
|
pub code_blocks_generated: u64,
|
||||||
|
pub session_code_blocks_generated: u64,
|
||||||
|
pub files_edited: u64,
|
||||||
|
pub session_files_edited: u64,
|
||||||
|
pub files_created: u64,
|
||||||
|
pub session_files_created: u64,
|
||||||
|
pub tools_usage: HashMap<String, u64>,
|
||||||
|
pub session_tools_usage: HashMap<String, u64>,
|
||||||
|
pub session_duration_seconds: u64,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub session_start: Option<Instant>,
|
||||||
|
|
||||||
|
// Extended tracking for achievements
|
||||||
|
pub sessions_started: u64,
|
||||||
|
pub consecutive_days: u64,
|
||||||
|
pub total_days_used: u64,
|
||||||
|
pub morning_sessions: u64, // Sessions started before 9 AM
|
||||||
|
pub night_sessions: u64, // Sessions started after 10 PM
|
||||||
|
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
||||||
|
|
||||||
|
// Achievement tracking
|
||||||
|
#[serde(skip)]
|
||||||
|
pub achievements: AchievementProgress,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageStats {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut stats = Self::default();
|
||||||
|
stats.achievements.start_session();
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
|
||||||
|
self.total_input_tokens += input_tokens;
|
||||||
|
self.total_output_tokens += output_tokens;
|
||||||
|
self.session_input_tokens += input_tokens;
|
||||||
|
self.session_output_tokens += output_tokens;
|
||||||
|
|
||||||
|
let cost = calculate_cost(input_tokens, output_tokens, model);
|
||||||
|
self.total_cost_usd += cost;
|
||||||
|
self.session_cost_usd += cost;
|
||||||
|
|
||||||
|
self.model = Some(model.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_session(&mut self) {
|
||||||
|
self.session_input_tokens = 0;
|
||||||
|
self.session_output_tokens = 0;
|
||||||
|
self.session_cost_usd = 0.0;
|
||||||
|
self.session_messages_exchanged = 0;
|
||||||
|
self.session_code_blocks_generated = 0;
|
||||||
|
self.session_files_edited = 0;
|
||||||
|
self.session_files_created = 0;
|
||||||
|
self.session_tools_usage.clear();
|
||||||
|
self.session_duration_seconds = 0;
|
||||||
|
self.session_start = Some(Instant::now());
|
||||||
|
self.achievements.start_session();
|
||||||
|
|
||||||
|
// Track session start for achievements
|
||||||
|
self.track_session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_session_start(&mut self) {
|
||||||
|
let now = Local::now();
|
||||||
|
let today = now.format("%Y-%m-%d").to_string();
|
||||||
|
let hour = now.hour();
|
||||||
|
|
||||||
|
// Increment session count
|
||||||
|
self.sessions_started += 1;
|
||||||
|
|
||||||
|
// Track morning/night sessions
|
||||||
|
if hour < 9 {
|
||||||
|
self.morning_sessions += 1;
|
||||||
|
}
|
||||||
|
if hour >= 22 {
|
||||||
|
self.night_sessions += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track consecutive days and total days
|
||||||
|
if let Some(last_date) = &self.last_session_date {
|
||||||
|
if last_date != &today {
|
||||||
|
// Check if it's the next day (consecutive)
|
||||||
|
if is_consecutive_day(last_date, &today) {
|
||||||
|
self.consecutive_days += 1;
|
||||||
|
} else {
|
||||||
|
// Streak broken
|
||||||
|
self.consecutive_days = 1;
|
||||||
|
}
|
||||||
|
self.total_days_used += 1;
|
||||||
|
self.last_session_date = Some(today);
|
||||||
|
}
|
||||||
|
// Same day - don't increment anything
|
||||||
|
} else {
|
||||||
|
// First session ever
|
||||||
|
self.consecutive_days = 1;
|
||||||
|
self.total_days_used = 1;
|
||||||
|
self.last_session_date = Some(today);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_messages(&mut self) {
|
||||||
|
self.messages_exchanged += 1;
|
||||||
|
self.session_messages_exchanged += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_code_blocks(&mut self) {
|
||||||
|
self.code_blocks_generated += 1;
|
||||||
|
self.session_code_blocks_generated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_files_edited(&mut self) {
|
||||||
|
self.files_edited += 1;
|
||||||
|
self.session_files_edited += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_files_created(&mut self) {
|
||||||
|
self.files_created += 1;
|
||||||
|
self.session_files_created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_tool_usage(&mut self, tool_name: &str) {
|
||||||
|
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
|
||||||
|
*self
|
||||||
|
.session_tools_usage
|
||||||
|
.entry(tool_name.to_string())
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_session_duration(&mut self) -> u64 {
|
||||||
|
// Only update if more than 1 second has passed to reduce calculations
|
||||||
|
if let Some(start) = self.session_start {
|
||||||
|
let elapsed = start.elapsed().as_secs();
|
||||||
|
if elapsed > self.session_duration_seconds {
|
||||||
|
self.session_duration_seconds = elapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.session_duration_seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_achievements(&mut self) -> Vec<crate::achievements::AchievementId> {
|
||||||
|
let stats_copy = UsageStats {
|
||||||
|
total_input_tokens: self.total_input_tokens,
|
||||||
|
total_output_tokens: self.total_output_tokens,
|
||||||
|
total_cost_usd: self.total_cost_usd,
|
||||||
|
session_input_tokens: self.session_input_tokens,
|
||||||
|
session_output_tokens: self.session_output_tokens,
|
||||||
|
session_cost_usd: self.session_cost_usd,
|
||||||
|
model: self.model.clone(),
|
||||||
|
messages_exchanged: self.messages_exchanged,
|
||||||
|
session_messages_exchanged: self.session_messages_exchanged,
|
||||||
|
code_blocks_generated: self.code_blocks_generated,
|
||||||
|
session_code_blocks_generated: self.session_code_blocks_generated,
|
||||||
|
files_edited: self.files_edited,
|
||||||
|
session_files_edited: self.session_files_edited,
|
||||||
|
files_created: self.files_created,
|
||||||
|
session_files_created: self.session_files_created,
|
||||||
|
tools_usage: self.tools_usage.clone(),
|
||||||
|
session_tools_usage: self.session_tools_usage.clone(),
|
||||||
|
session_duration_seconds: self.session_duration_seconds,
|
||||||
|
session_start: self.session_start,
|
||||||
|
sessions_started: self.sessions_started,
|
||||||
|
consecutive_days: self.consecutive_days,
|
||||||
|
total_days_used: self.total_days_used,
|
||||||
|
morning_sessions: self.morning_sessions,
|
||||||
|
night_sessions: self.night_sessions,
|
||||||
|
last_session_date: self.last_session_date.clone(),
|
||||||
|
achievements: AchievementProgress::new(), // Dummy for copy
|
||||||
|
};
|
||||||
|
check_achievements(&stats_copy, &mut self.achievements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if two dates are consecutive
|
||||||
|
fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
let prev = NaiveDate::parse_from_str(prev_date, "%Y-%m-%d").ok();
|
||||||
|
let current = NaiveDate::parse_from_str(current_date, "%Y-%m-%d").ok();
|
||||||
|
|
||||||
|
match (prev, current) {
|
||||||
|
(Some(p), Some(c)) => {
|
||||||
|
let diff = c.signed_duration_since(p).num_days();
|
||||||
|
diff == 1
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pricing as of January 2025
|
||||||
|
// https://www.anthropic.com/pricing
|
||||||
|
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
||||||
|
let (input_price_per_million, output_price_per_million) = match model {
|
||||||
|
// Opus 4.5
|
||||||
|
"claude-opus-4-5-20251101" => (15.0, 75.0),
|
||||||
|
|
||||||
|
// Opus 4
|
||||||
|
"claude-opus-4-20250514" => (15.0, 75.0),
|
||||||
|
|
||||||
|
// Sonnet 4
|
||||||
|
"claude-sonnet-4-20250514" => (3.0, 15.0),
|
||||||
|
|
||||||
|
// Previous generation models
|
||||||
|
"claude-3-5-sonnet-20241022" => (3.0, 15.0),
|
||||||
|
"claude-3-5-sonnet-20240620" => (3.0, 15.0),
|
||||||
|
"claude-3-5-haiku-20241022" => (1.0, 5.0),
|
||||||
|
"claude-3-opus-20240229" => (15.0, 75.0),
|
||||||
|
"claude-3-sonnet-20240229" => (3.0, 15.0),
|
||||||
|
"claude-3-haiku-20240307" => (0.25, 1.25),
|
||||||
|
|
||||||
|
// Default to Sonnet pricing if model unknown
|
||||||
|
_ => (3.0, 15.0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
|
||||||
|
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
|
||||||
|
|
||||||
|
input_cost + output_cost
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StatsUpdateEvent {
|
||||||
|
pub stats: UsageStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable struct for persisting only lifetime (total) stats
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PersistedStats {
|
||||||
|
pub total_input_tokens: u64,
|
||||||
|
pub total_output_tokens: u64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub messages_exchanged: u64,
|
||||||
|
pub code_blocks_generated: u64,
|
||||||
|
pub files_edited: u64,
|
||||||
|
pub files_created: u64,
|
||||||
|
pub tools_usage: HashMap<String, u64>,
|
||||||
|
pub sessions_started: u64,
|
||||||
|
pub consecutive_days: u64,
|
||||||
|
pub total_days_used: u64,
|
||||||
|
pub morning_sessions: u64,
|
||||||
|
pub night_sessions: u64,
|
||||||
|
pub last_session_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&UsageStats> for PersistedStats {
|
||||||
|
fn from(stats: &UsageStats) -> Self {
|
||||||
|
PersistedStats {
|
||||||
|
total_input_tokens: stats.total_input_tokens,
|
||||||
|
total_output_tokens: stats.total_output_tokens,
|
||||||
|
total_cost_usd: stats.total_cost_usd,
|
||||||
|
messages_exchanged: stats.messages_exchanged,
|
||||||
|
code_blocks_generated: stats.code_blocks_generated,
|
||||||
|
files_edited: stats.files_edited,
|
||||||
|
files_created: stats.files_created,
|
||||||
|
tools_usage: stats.tools_usage.clone(),
|
||||||
|
sessions_started: stats.sessions_started,
|
||||||
|
consecutive_days: stats.consecutive_days,
|
||||||
|
total_days_used: stats.total_days_used,
|
||||||
|
morning_sessions: stats.morning_sessions,
|
||||||
|
night_sessions: stats.night_sessions,
|
||||||
|
last_session_date: stats.last_session_date.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageStats {
|
||||||
|
/// Apply persisted stats to restore lifetime totals
|
||||||
|
pub fn apply_persisted(&mut self, persisted: PersistedStats) {
|
||||||
|
self.total_input_tokens = persisted.total_input_tokens;
|
||||||
|
self.total_output_tokens = persisted.total_output_tokens;
|
||||||
|
self.total_cost_usd = persisted.total_cost_usd;
|
||||||
|
self.messages_exchanged = persisted.messages_exchanged;
|
||||||
|
self.code_blocks_generated = persisted.code_blocks_generated;
|
||||||
|
self.files_edited = persisted.files_edited;
|
||||||
|
self.files_created = persisted.files_created;
|
||||||
|
self.tools_usage = persisted.tools_usage;
|
||||||
|
self.sessions_started = persisted.sessions_started;
|
||||||
|
self.consecutive_days = persisted.consecutive_days;
|
||||||
|
self.total_days_used = persisted.total_days_used;
|
||||||
|
self.morning_sessions = persisted.morning_sessions;
|
||||||
|
self.night_sessions = persisted.night_sessions;
|
||||||
|
self.last_session_date = persisted.last_session_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save lifetime stats to persistent store
|
||||||
|
pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<(), String> {
|
||||||
|
let store = app.store("stats.json").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let persisted = PersistedStats::from(stats);
|
||||||
|
|
||||||
|
println!("Saving stats: {:?}", persisted);
|
||||||
|
|
||||||
|
store.set(
|
||||||
|
"lifetime_stats",
|
||||||
|
serde_json::to_value(persisted).map_err(|e| e.to_string())?,
|
||||||
|
);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
println!("Stats saved successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load lifetime stats from persistent store
|
||||||
|
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
|
||||||
|
println!("Loading stats from store...");
|
||||||
|
|
||||||
|
let store = match app.store("stats.json") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
println!("Failed to open stats store: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(stats_value) = store.get("lifetime_stats") {
|
||||||
|
println!("Found lifetime stats in store: {:?}", stats_value);
|
||||||
|
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
|
||||||
|
println!("Loaded lifetime stats successfully");
|
||||||
|
return Some(persisted);
|
||||||
|
} else {
|
||||||
|
println!("Failed to parse lifetime stats");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("No lifetime stats found in store");
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_calculation_sonnet() {
|
||||||
|
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514");
|
||||||
|
// 1000 input * $3/M = $0.003
|
||||||
|
// 2000 output * $15/M = $0.030
|
||||||
|
// Total = $0.033
|
||||||
|
assert!((cost - 0.033).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_calculation_opus() {
|
||||||
|
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514");
|
||||||
|
// 1000 input * $15/M = $0.015
|
||||||
|
// 2000 output * $75/M = $0.150
|
||||||
|
// Total = $0.165
|
||||||
|
assert!((cost - 0.165).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_calculation_opus_45() {
|
||||||
|
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
|
||||||
|
// Same pricing as Opus 4
|
||||||
|
assert!((cost - 0.165).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_calculation_haiku() {
|
||||||
|
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022");
|
||||||
|
// 1000 input * $1/M = $0.001
|
||||||
|
// 2000 output * $5/M = $0.010
|
||||||
|
// Total = $0.011
|
||||||
|
assert!((cost - 0.011).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_calculation_unknown_defaults_to_sonnet() {
|
||||||
|
let cost = calculate_cost(1000, 2000, "some-unknown-model");
|
||||||
|
// Should default to Sonnet pricing
|
||||||
|
assert!((cost - 0.033).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cost_calculation_legacy_sonnet() {
|
||||||
|
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022");
|
||||||
|
// Same as Sonnet 4 pricing
|
||||||
|
assert!((cost - 0.033).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_usage_stats_accumulation() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||||
|
|
||||||
|
assert_eq!(stats.total_input_tokens, 1000);
|
||||||
|
assert_eq!(stats.total_output_tokens, 2000);
|
||||||
|
assert_eq!(stats.session_input_tokens, 1000);
|
||||||
|
assert_eq!(stats.session_output_tokens, 2000);
|
||||||
|
assert!((stats.total_cost_usd - 0.033).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_usage_stats_multiple_accumulations() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
||||||
|
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
|
||||||
|
|
||||||
|
assert_eq!(stats.total_input_tokens, 1500);
|
||||||
|
assert_eq!(stats.total_output_tokens, 1500);
|
||||||
|
assert_eq!(stats.session_input_tokens, 1500);
|
||||||
|
assert_eq!(stats.session_output_tokens, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_usage_stats_model_updated() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
||||||
|
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
|
|
||||||
|
stats.add_usage(500, 500, "claude-opus-4-20250514");
|
||||||
|
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_reset() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||||
|
stats.reset_session();
|
||||||
|
|
||||||
|
assert_eq!(stats.total_input_tokens, 1000);
|
||||||
|
assert_eq!(stats.total_output_tokens, 2000);
|
||||||
|
assert_eq!(stats.session_input_tokens, 0);
|
||||||
|
assert_eq!(stats.session_output_tokens, 0);
|
||||||
|
assert_eq!(stats.session_cost_usd, 0.0);
|
||||||
|
assert!(stats.total_cost_usd > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_reset_clears_session_stats() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.increment_messages();
|
||||||
|
stats.increment_messages();
|
||||||
|
stats.increment_code_blocks();
|
||||||
|
stats.increment_files_edited();
|
||||||
|
stats.increment_files_created();
|
||||||
|
stats.increment_tool_usage("Read");
|
||||||
|
|
||||||
|
stats.reset_session();
|
||||||
|
|
||||||
|
assert_eq!(stats.session_messages_exchanged, 0);
|
||||||
|
assert_eq!(stats.session_code_blocks_generated, 0);
|
||||||
|
assert_eq!(stats.session_files_edited, 0);
|
||||||
|
assert_eq!(stats.session_files_created, 0);
|
||||||
|
assert!(stats.session_tools_usage.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_increment_messages() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.increment_messages();
|
||||||
|
stats.increment_messages();
|
||||||
|
stats.increment_messages();
|
||||||
|
|
||||||
|
assert_eq!(stats.messages_exchanged, 3);
|
||||||
|
assert_eq!(stats.session_messages_exchanged, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_increment_code_blocks() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.increment_code_blocks();
|
||||||
|
stats.increment_code_blocks();
|
||||||
|
|
||||||
|
assert_eq!(stats.code_blocks_generated, 2);
|
||||||
|
assert_eq!(stats.session_code_blocks_generated, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_increment_files_edited() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.increment_files_edited();
|
||||||
|
|
||||||
|
assert_eq!(stats.files_edited, 1);
|
||||||
|
assert_eq!(stats.session_files_edited, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_increment_files_created() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.increment_files_created();
|
||||||
|
|
||||||
|
assert_eq!(stats.files_created, 1);
|
||||||
|
assert_eq!(stats.session_files_created, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_increment_tool_usage() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.increment_tool_usage("Read");
|
||||||
|
stats.increment_tool_usage("Read");
|
||||||
|
stats.increment_tool_usage("Write");
|
||||||
|
|
||||||
|
assert_eq!(stats.tools_usage.get("Read"), Some(&2));
|
||||||
|
assert_eq!(stats.tools_usage.get("Write"), Some(&1));
|
||||||
|
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2));
|
||||||
|
assert_eq!(stats.session_tools_usage.get("Write"), Some(&1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_duration_tracking() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_start = Some(Instant::now());
|
||||||
|
|
||||||
|
// Verify duration is returned (u64 is always non-negative)
|
||||||
|
let _duration = stats.get_session_duration();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_duration_without_start() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.session_start = None;
|
||||||
|
stats.session_duration_seconds = 100;
|
||||||
|
|
||||||
|
// Should return the stored value when no start time
|
||||||
|
let duration = stats.get_session_duration();
|
||||||
|
assert_eq!(duration, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_consecutive_day_true() {
|
||||||
|
assert!(is_consecutive_day("2024-01-15", "2024-01-16"));
|
||||||
|
assert!(is_consecutive_day("2024-12-31", "2025-01-01"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_consecutive_day_false() {
|
||||||
|
assert!(!is_consecutive_day("2024-01-15", "2024-01-15")); // Same day
|
||||||
|
assert!(!is_consecutive_day("2024-01-15", "2024-01-17")); // Gap
|
||||||
|
assert!(!is_consecutive_day("2024-01-15", "2024-01-14")); // Backwards
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_consecutive_day_invalid_dates() {
|
||||||
|
assert!(!is_consecutive_day("invalid", "2024-01-01"));
|
||||||
|
assert!(!is_consecutive_day("2024-01-01", "invalid"));
|
||||||
|
assert!(!is_consecutive_day("invalid", "also-invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_persisted_stats_from_usage_stats() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.total_input_tokens = 5000;
|
||||||
|
stats.total_output_tokens = 10000;
|
||||||
|
stats.total_cost_usd = 1.23;
|
||||||
|
stats.messages_exchanged = 50;
|
||||||
|
stats.sessions_started = 5;
|
||||||
|
stats.consecutive_days = 3;
|
||||||
|
|
||||||
|
let persisted = PersistedStats::from(&stats);
|
||||||
|
|
||||||
|
assert_eq!(persisted.total_input_tokens, 5000);
|
||||||
|
assert_eq!(persisted.total_output_tokens, 10000);
|
||||||
|
assert_eq!(persisted.total_cost_usd, 1.23);
|
||||||
|
assert_eq!(persisted.messages_exchanged, 50);
|
||||||
|
assert_eq!(persisted.sessions_started, 5);
|
||||||
|
assert_eq!(persisted.consecutive_days, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_persisted_stats() {
|
||||||
|
let persisted = PersistedStats {
|
||||||
|
total_input_tokens: 10000,
|
||||||
|
total_output_tokens: 20000,
|
||||||
|
total_cost_usd: 5.50,
|
||||||
|
messages_exchanged: 100,
|
||||||
|
code_blocks_generated: 25,
|
||||||
|
files_edited: 10,
|
||||||
|
files_created: 5,
|
||||||
|
tools_usage: {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert("Read".to_string(), 50);
|
||||||
|
map
|
||||||
|
},
|
||||||
|
sessions_started: 10,
|
||||||
|
consecutive_days: 7,
|
||||||
|
total_days_used: 14,
|
||||||
|
morning_sessions: 3,
|
||||||
|
night_sessions: 2,
|
||||||
|
last_session_date: Some("2024-06-15".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.apply_persisted(persisted);
|
||||||
|
|
||||||
|
assert_eq!(stats.total_input_tokens, 10000);
|
||||||
|
assert_eq!(stats.total_output_tokens, 20000);
|
||||||
|
assert_eq!(stats.total_cost_usd, 5.50);
|
||||||
|
assert_eq!(stats.messages_exchanged, 100);
|
||||||
|
assert_eq!(stats.tools_usage.get("Read"), Some(&50));
|
||||||
|
assert_eq!(stats.consecutive_days, 7);
|
||||||
|
assert_eq!(stats.morning_sessions, 3);
|
||||||
|
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_usage_stats_default() {
|
||||||
|
let stats = UsageStats::default();
|
||||||
|
|
||||||
|
assert_eq!(stats.total_input_tokens, 0);
|
||||||
|
assert_eq!(stats.total_output_tokens, 0);
|
||||||
|
assert_eq!(stats.total_cost_usd, 0.0);
|
||||||
|
assert!(stats.model.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_persisted_stats_default() {
|
||||||
|
let persisted = PersistedStats::default();
|
||||||
|
|
||||||
|
assert_eq!(persisted.total_input_tokens, 0);
|
||||||
|
assert!(persisted.last_session_date.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_usage_stats_serialization() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||||
|
stats.increment_messages();
|
||||||
|
|
||||||
|
// UsageStats should be serializable (for events)
|
||||||
|
let json = serde_json::to_string(&stats).expect("Failed to serialize");
|
||||||
|
assert!(json.contains("total_input_tokens"));
|
||||||
|
assert!(json.contains("1000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_persisted_stats_serialization() {
|
||||||
|
let persisted = PersistedStats {
|
||||||
|
total_input_tokens: 1234,
|
||||||
|
total_output_tokens: 5678,
|
||||||
|
total_cost_usd: 0.99,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&persisted).expect("Failed to serialize");
|
||||||
|
let parsed: PersistedStats = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(parsed.total_input_tokens, 1234);
|
||||||
|
assert_eq!(parsed.total_output_tokens, 5678);
|
||||||
|
assert!((parsed.total_cost_usd - 0.99).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stats_update_event_serialization() {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
stats.add_usage(100, 200, "claude-sonnet-4-20250514");
|
||||||
|
|
||||||
|
let event = StatsUpdateEvent { stats };
|
||||||
|
let json = serde_json::to_string(&event).expect("Failed to serialize");
|
||||||
|
|
||||||
|
assert!(json.contains("stats"));
|
||||||
|
assert!(json.contains("total_input_tokens"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const TEMP_DIR_NAME: &str = "hikari-uploads";
|
||||||
|
|
||||||
|
pub struct TempFileManager {
|
||||||
|
base_dir: PathBuf,
|
||||||
|
files: HashMap<String, Vec<PathBuf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempFileManager {
|
||||||
|
pub fn new() -> Result<Self, String> {
|
||||||
|
let base_dir = std::env::temp_dir().join(TEMP_DIR_NAME);
|
||||||
|
|
||||||
|
if !base_dir.exists() {
|
||||||
|
fs::create_dir_all(&base_dir)
|
||||||
|
.map_err(|e| format!("Failed to create temp directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TempFileManager {
|
||||||
|
base_dir,
|
||||||
|
files: HashMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_base_dir(&self) -> &Path {
|
||||||
|
&self.base_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_file(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: &str,
|
||||||
|
data: &[u8],
|
||||||
|
original_filename: Option<&str>,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let unique_id = Uuid::new_v4();
|
||||||
|
let extension = original_filename
|
||||||
|
.and_then(|name| Path::new(name).extension())
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.unwrap_or("bin");
|
||||||
|
|
||||||
|
let filename = format!("{}_{}.{}", conversation_id, unique_id, extension);
|
||||||
|
let file_path = self.base_dir.join(&filename);
|
||||||
|
|
||||||
|
fs::write(&file_path, data)
|
||||||
|
.map_err(|e| format!("Failed to write temp file: {}", e))?;
|
||||||
|
|
||||||
|
self.files
|
||||||
|
.entry(conversation_id.to_string())
|
||||||
|
.or_default()
|
||||||
|
.push(file_path.clone());
|
||||||
|
|
||||||
|
Ok(file_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_file(&mut self, conversation_id: &str, file_path: PathBuf) {
|
||||||
|
self.files
|
||||||
|
.entry(conversation_id.to_string())
|
||||||
|
.or_default()
|
||||||
|
.push(file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_files_for_conversation(&self, conversation_id: &str) -> Vec<PathBuf> {
|
||||||
|
self.files
|
||||||
|
.get(conversation_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cleanup_conversation(&mut self, conversation_id: &str) -> Result<(), String> {
|
||||||
|
if let Some(files) = self.files.remove(conversation_id) {
|
||||||
|
for file_path in files {
|
||||||
|
if file_path.exists() {
|
||||||
|
if let Err(e) = fs::remove_file(&file_path) {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: Failed to remove temp file {:?}: {}",
|
||||||
|
file_path, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cleanup_all(&mut self) -> Result<(), String> {
|
||||||
|
let conversation_ids: Vec<String> = self.files.keys().cloned().collect();
|
||||||
|
|
||||||
|
for conversation_id in conversation_ids {
|
||||||
|
self.cleanup_conversation(&conversation_id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cleanup_orphaned_files(&mut self) -> Result<usize, String> {
|
||||||
|
let mut cleaned_count = 0;
|
||||||
|
|
||||||
|
if !self.base_dir.exists() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tracked_files: std::collections::HashSet<PathBuf> =
|
||||||
|
self.files.values().flatten().cloned().collect();
|
||||||
|
|
||||||
|
let entries = fs::read_dir(&self.base_dir)
|
||||||
|
.map_err(|e| format!("Failed to read temp directory: {}", e))?;
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() && !tracked_files.contains(&path) {
|
||||||
|
if let Err(e) = fs::remove_file(&path) {
|
||||||
|
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
|
||||||
|
} else {
|
||||||
|
cleaned_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(cleaned_count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TempFileManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new().expect("Failed to create TempFileManager")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedTempFileManager = Arc<Mutex<TempFileManager>>;
|
||||||
|
|
||||||
|
pub fn create_shared_temp_manager() -> Result<SharedTempFileManager, String> {
|
||||||
|
Ok(Arc::new(Mutex::new(TempFileManager::new()?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// Helper to create a TempFileManager with a custom base directory for testing
|
||||||
|
fn create_test_manager(base_dir: PathBuf) -> TempFileManager {
|
||||||
|
if !base_dir.exists() {
|
||||||
|
fs::create_dir_all(&base_dir).expect("Failed to create test temp dir");
|
||||||
|
}
|
||||||
|
TempFileManager {
|
||||||
|
base_dir,
|
||||||
|
files: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_creates_base_directory() {
|
||||||
|
let manager = TempFileManager::new().expect("Failed to create TempFileManager");
|
||||||
|
assert!(manager.base_dir.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_base_dir_returns_correct_path() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let manager = create_test_manager(base_path.clone());
|
||||||
|
|
||||||
|
assert_eq!(manager.get_base_dir(), base_path.as_path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_file_creates_file_with_content() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let data = b"Hello, world!";
|
||||||
|
let result = manager.save_file("conv-1", data, Some("test.txt"));
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let file_path = result.unwrap();
|
||||||
|
assert!(file_path.exists());
|
||||||
|
|
||||||
|
let content = fs::read(&file_path).expect("Failed to read file");
|
||||||
|
assert_eq!(content, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_file_uses_correct_extension() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let data = b"test data";
|
||||||
|
let result = manager.save_file("conv-1", data, Some("document.pdf"));
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let file_path = result.unwrap();
|
||||||
|
assert_eq!(file_path.extension().unwrap(), "pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_file_uses_bin_extension_when_no_filename() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let data = b"binary data";
|
||||||
|
let result = manager.save_file("conv-1", data, None);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let file_path = result.unwrap();
|
||||||
|
assert_eq!(file_path.extension().unwrap(), "bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_register_file_tracks_file_path() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let file_path = PathBuf::from("/some/path/file.txt");
|
||||||
|
manager.register_file("conv-1", file_path.clone());
|
||||||
|
|
||||||
|
let files = manager.get_files_for_conversation("conv-1");
|
||||||
|
assert_eq!(files.len(), 1);
|
||||||
|
assert_eq!(files[0], file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_files_for_conversation_returns_empty_for_unknown() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let files = manager.get_files_for_conversation("unknown-conv");
|
||||||
|
assert!(files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_files_for_conversation_returns_all_files() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let data = b"test";
|
||||||
|
manager.save_file("conv-1", data, Some("file1.txt")).unwrap();
|
||||||
|
manager.save_file("conv-1", data, Some("file2.txt")).unwrap();
|
||||||
|
manager.save_file("conv-2", data, Some("file3.txt")).unwrap();
|
||||||
|
|
||||||
|
let files_conv1 = manager.get_files_for_conversation("conv-1");
|
||||||
|
let files_conv2 = manager.get_files_for_conversation("conv-2");
|
||||||
|
|
||||||
|
assert_eq!(files_conv1.len(), 2);
|
||||||
|
assert_eq!(files_conv2.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cleanup_conversation_removes_files() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let data = b"test";
|
||||||
|
let file_path = manager.save_file("conv-1", data, Some("test.txt")).unwrap();
|
||||||
|
assert!(file_path.exists());
|
||||||
|
|
||||||
|
let result = manager.cleanup_conversation("conv-1");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(!file_path.exists());
|
||||||
|
assert!(manager.get_files_for_conversation("conv-1").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cleanup_conversation_handles_missing_files() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
// Register a file that doesn't exist
|
||||||
|
manager.register_file("conv-1", PathBuf::from("/nonexistent/file.txt"));
|
||||||
|
|
||||||
|
// Should not error, just skip missing files
|
||||||
|
let result = manager.cleanup_conversation("conv-1");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cleanup_conversation_for_unknown_returns_ok() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let result = manager.cleanup_conversation("unknown-conv");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cleanup_all_removes_all_files() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let data = b"test";
|
||||||
|
let file1 = manager.save_file("conv-1", data, Some("f1.txt")).unwrap();
|
||||||
|
let file2 = manager.save_file("conv-2", data, Some("f2.txt")).unwrap();
|
||||||
|
|
||||||
|
assert!(file1.exists());
|
||||||
|
assert!(file2.exists());
|
||||||
|
|
||||||
|
let result = manager.cleanup_all();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
assert!(!file1.exists());
|
||||||
|
assert!(!file2.exists());
|
||||||
|
assert!(manager.files.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cleanup_orphaned_files_removes_untracked() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path.clone());
|
||||||
|
|
||||||
|
// Create a tracked file
|
||||||
|
let data = b"tracked";
|
||||||
|
let tracked_path = manager.save_file("conv-1", data, Some("tracked.txt")).unwrap();
|
||||||
|
|
||||||
|
// Create an untracked (orphaned) file directly in the temp directory
|
||||||
|
let orphan_path = base_path.join("orphan.txt");
|
||||||
|
fs::write(&orphan_path, b"orphan").expect("Failed to create orphan file");
|
||||||
|
|
||||||
|
assert!(tracked_path.exists());
|
||||||
|
assert!(orphan_path.exists());
|
||||||
|
|
||||||
|
let result = manager.cleanup_orphaned_files();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), 1); // One orphan removed
|
||||||
|
|
||||||
|
assert!(tracked_path.exists()); // Tracked file still exists
|
||||||
|
assert!(!orphan_path.exists()); // Orphan removed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cleanup_orphaned_returns_zero_when_none() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let data = b"test";
|
||||||
|
manager.save_file("conv-1", data, Some("test.txt")).unwrap();
|
||||||
|
|
||||||
|
let result = manager.cleanup_orphaned_files();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cleanup_orphaned_returns_zero_when_dir_missing() {
|
||||||
|
let mut manager = TempFileManager {
|
||||||
|
base_dir: PathBuf::from("/nonexistent/dir"),
|
||||||
|
files: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = manager.cleanup_orphaned_files();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_creates_manager() {
|
||||||
|
// Default should work as long as we can create temp directories
|
||||||
|
let manager = TempFileManager::default();
|
||||||
|
assert!(manager.base_dir.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_shared_temp_manager() {
|
||||||
|
let result = create_shared_temp_manager();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let shared = result.unwrap();
|
||||||
|
let manager = shared.lock();
|
||||||
|
assert!(manager.base_dir.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_files_same_conversation() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
// Save multiple files to same conversation
|
||||||
|
for i in 0..5 {
|
||||||
|
let data = format!("content {}", i);
|
||||||
|
manager
|
||||||
|
.save_file("conv-1", data.as_bytes(), Some(&format!("file{}.txt", i)))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = manager.get_files_for_conversation("conv-1");
|
||||||
|
assert_eq!(files.len(), 5);
|
||||||
|
|
||||||
|
// Each file should have unique content
|
||||||
|
for (i, file_path) in files.iter().enumerate() {
|
||||||
|
let content = fs::read_to_string(file_path).expect("Failed to read");
|
||||||
|
assert_eq!(content, format!("content {}", i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_paths_contain_conversation_id() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let base_path = temp_dir.path().join("hikari-test");
|
||||||
|
let mut manager = create_test_manager(base_path);
|
||||||
|
|
||||||
|
let file_path = manager
|
||||||
|
.save_file("my-conversation-id", b"test", Some("test.txt"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let filename = file_path.file_name().unwrap().to_str().unwrap();
|
||||||
|
assert!(filename.starts_with("my-conversation-id_"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
AppHandle, Manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::config::HikariConfig;
|
||||||
|
|
||||||
|
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||||
|
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
|
||||||
|
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||||
|
|
||||||
|
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
|
||||||
|
|
||||||
|
let _tray = TrayIconBuilder::with_id("main")
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.menu(&menu)
|
||||||
|
.tooltip("Hikari - Claude Code Assistant")
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"show" => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
let app = tray.app_handle();
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
|
||||||
|
let config_path = app
|
||||||
|
.path()
|
||||||
|
.app_config_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.join("hikari-config.json"));
|
||||||
|
|
||||||
|
if let Some(path) = config_path {
|
||||||
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||||
|
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
|
||||||
|
return config.minimize_to_tray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UsageInfo {
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum CharacterState {
|
pub enum CharacterState {
|
||||||
|
#[default]
|
||||||
Idle,
|
Idle,
|
||||||
Thinking,
|
Thinking,
|
||||||
Typing,
|
Typing,
|
||||||
@@ -14,27 +21,17 @@ pub enum CharacterState {
|
|||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CharacterState {
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
fn default() -> Self {
|
|
||||||
CharacterState::Idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ConnectionStatus {
|
pub enum ConnectionStatus {
|
||||||
|
#[default]
|
||||||
Disconnected,
|
Disconnected,
|
||||||
Connecting,
|
Connecting,
|
||||||
Connected,
|
Connected,
|
||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ConnectionStatus {
|
#[allow(dead_code)]
|
||||||
fn default() -> Self {
|
|
||||||
ConnectionStatus::Disconnected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TerminalLine {
|
pub struct TerminalLine {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -46,6 +43,7 @@ pub struct TerminalLine {
|
|||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PermissionRequest {
|
pub struct PermissionRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -95,6 +93,8 @@ pub enum ClaudeMessage {
|
|||||||
num_turns: Option<u32>,
|
num_turns: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
permission_denials: Option<Vec<PermissionDenial>>,
|
permission_denials: Option<Vec<PermissionDenial>>,
|
||||||
|
#[serde(default)]
|
||||||
|
usage: Option<UsageInfo>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +105,8 @@ pub struct AssistantMessageContent {
|
|||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stop_reason: Option<String>,
|
pub stop_reason: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub usage: Option<UsageInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -170,6 +172,8 @@ pub struct DeltaContent {
|
|||||||
pub struct StateChangeEvent {
|
pub struct StateChangeEvent {
|
||||||
pub state: CharacterState,
|
pub state: CharacterState,
|
||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -177,6 +181,8 @@ pub struct OutputEvent {
|
|||||||
pub line_type: String,
|
pub line_type: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -185,4 +191,173 @@ pub struct PermissionPromptEvent {
|
|||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
pub tool_input: serde_json::Value,
|
pub tool_input: serde_json::Value,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConnectionEvent {
|
||||||
|
pub status: ConnectionStatus,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionEvent {
|
||||||
|
pub session_id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorkingDirectoryEvent {
|
||||||
|
pub directory: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QuestionOption {
|
||||||
|
pub label: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UserQuestionEvent {
|
||||||
|
pub id: String,
|
||||||
|
pub question: String,
|
||||||
|
pub header: Option<String>,
|
||||||
|
pub options: Vec<QuestionOption>,
|
||||||
|
pub multi_select: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_character_state_default() {
|
||||||
|
let state = CharacterState::default();
|
||||||
|
assert_eq!(state, CharacterState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connection_status_default() {
|
||||||
|
let status = ConnectionStatus::default();
|
||||||
|
matches!(status, ConnectionStatus::Disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_character_state_serialization() {
|
||||||
|
let state = CharacterState::Thinking;
|
||||||
|
let serialized = serde_json::to_string(&state).unwrap();
|
||||||
|
assert_eq!(serialized, "\"thinking\"");
|
||||||
|
|
||||||
|
let deserialized: CharacterState = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(deserialized, CharacterState::Thinking);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_character_states_serialize() {
|
||||||
|
let states = vec![
|
||||||
|
(CharacterState::Idle, "\"idle\""),
|
||||||
|
(CharacterState::Thinking, "\"thinking\""),
|
||||||
|
(CharacterState::Typing, "\"typing\""),
|
||||||
|
(CharacterState::Searching, "\"searching\""),
|
||||||
|
(CharacterState::Coding, "\"coding\""),
|
||||||
|
(CharacterState::Mcp, "\"mcp\""),
|
||||||
|
(CharacterState::Permission, "\"permission\""),
|
||||||
|
(CharacterState::Success, "\"success\""),
|
||||||
|
(CharacterState::Error, "\"error\""),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (state, expected) in states {
|
||||||
|
let serialized = serde_json::to_string(&state).unwrap();
|
||||||
|
assert_eq!(serialized, expected, "Failed for state: {:?}", state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_terminal_line_serialization() {
|
||||||
|
let line = TerminalLine {
|
||||||
|
id: "test-123".to_string(),
|
||||||
|
line_type: "assistant".to_string(),
|
||||||
|
content: "Hello, world!".to_string(),
|
||||||
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
tool_name: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&line).unwrap();
|
||||||
|
assert!(serialized.contains("\"type\":\"assistant\""));
|
||||||
|
assert!(serialized.contains("\"content\":\"Hello, world!\""));
|
||||||
|
assert!(!serialized.contains("tool_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_terminal_line_with_tool_name() {
|
||||||
|
let line = TerminalLine {
|
||||||
|
id: "test-456".to_string(),
|
||||||
|
line_type: "tool".to_string(),
|
||||||
|
content: "Reading file...".to_string(),
|
||||||
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
tool_name: Some("Read".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&line).unwrap();
|
||||||
|
assert!(serialized.contains("\"tool_name\":\"Read\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_content_block_text() {
|
||||||
|
let block = ContentBlock::Text {
|
||||||
|
text: "Hello!".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&block).unwrap();
|
||||||
|
assert!(serialized.contains("\"type\":\"text\""));
|
||||||
|
assert!(serialized.contains("\"text\":\"Hello!\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_content_block_tool_use() {
|
||||||
|
let block = ContentBlock::ToolUse {
|
||||||
|
id: "tool-123".to_string(),
|
||||||
|
name: "Read".to_string(),
|
||||||
|
input: serde_json::json!({"file_path": "/test.txt"}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&block).unwrap();
|
||||||
|
assert!(serialized.contains("\"type\":\"tool_use\""));
|
||||||
|
assert!(serialized.contains("\"name\":\"Read\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_change_event() {
|
||||||
|
let event = StateChangeEvent {
|
||||||
|
state: CharacterState::Coding,
|
||||||
|
tool_name: Some("Edit".to_string()),
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"state\":\"coding\""));
|
||||||
|
assert!(serialized.contains("\"tool_name\":\"Edit\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_output_event() {
|
||||||
|
let event = OutputEvent {
|
||||||
|
line_type: "assistant".to_string(),
|
||||||
|
content: "Test output".to_string(),
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(serialized.contains("\"line_type\":\"assistant\""));
|
||||||
|
assert!(serialized.contains("\"content\":\"Test output\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use std::io::Write;
|
||||||
|
use std::process::Command;
|
||||||
|
use tauri::command;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
|
||||||
|
// Create a VBScript that shows a Windows notification
|
||||||
|
let vbs_content = format!(
|
||||||
|
r#"
|
||||||
|
Set objShell = CreateObject("WScript.Shell")
|
||||||
|
objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
||||||
|
"#,
|
||||||
|
body.replace("\"", "\"\"").replace("\n", "\" & vbCrLf & \""),
|
||||||
|
title.replace("\"", "\"\""),
|
||||||
|
title.replace("\"", "\"\"")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a temporary VBS file
|
||||||
|
let mut temp_file =
|
||||||
|
NamedTempFile::new().map_err(|e| format!("Failed to create temp file: {}", e))?;
|
||||||
|
|
||||||
|
temp_file
|
||||||
|
.write_all(vbs_content.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write VBS content: {}", e))?;
|
||||||
|
|
||||||
|
let temp_path = temp_file.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Convert WSL path to Windows path
|
||||||
|
let windows_path = if temp_path.starts_with("/mnt/") {
|
||||||
|
// Convert /mnt/c/... to C:\...
|
||||||
|
let path_parts: Vec<&str> = temp_path.split('/').collect();
|
||||||
|
if path_parts.len() > 2 {
|
||||||
|
let drive_letter = path_parts[2].to_uppercase();
|
||||||
|
let rest_of_path = path_parts[3..].join("\\");
|
||||||
|
format!("{}:\\{}", drive_letter, rest_of_path)
|
||||||
|
} else {
|
||||||
|
temp_path.clone()
|
||||||
|
}
|
||||||
|
} else if temp_path.starts_with("/tmp/") {
|
||||||
|
// WSL temp files might be in a different location
|
||||||
|
// Try to use wslpath to convert
|
||||||
|
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
|
||||||
|
|
||||||
|
if let Ok(result) = output {
|
||||||
|
if result.status.success() {
|
||||||
|
String::from_utf8_lossy(&result.stdout).trim().to_string()
|
||||||
|
} else {
|
||||||
|
temp_path.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
temp_path.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
temp_path.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute the VBScript using wscript.exe
|
||||||
|
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
|
||||||
|
.arg("//NoLogo")
|
||||||
|
.arg(&windows_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to execute VBScript: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let error = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("VBScript execution failed: {}", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use tauri::command;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use windows::{
|
||||||
|
core::{Result as WindowsResult, HSTRING},
|
||||||
|
Data::Xml::Dom::*,
|
||||||
|
UI::Notifications::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[command]
|
||||||
|
pub async fn send_windows_toast(title: String, body: String) -> Result<(), String> {
|
||||||
|
show_toast_notification(&title, &body)
|
||||||
|
.map_err(|e| format!("Failed to show toast notification: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn show_toast_notification(title: &str, body: &str) -> WindowsResult<()> {
|
||||||
|
// Create the XML for the toast notification
|
||||||
|
let toast_xml = format!(
|
||||||
|
r#"<toast>
|
||||||
|
<visual>
|
||||||
|
<binding template="ToastGeneric">
|
||||||
|
<text>{}</text>
|
||||||
|
<text>{}</text>
|
||||||
|
</binding>
|
||||||
|
</visual>
|
||||||
|
<audio src="ms-winsoundevent:Notification.Default" />
|
||||||
|
</toast>"#,
|
||||||
|
escape_xml(title),
|
||||||
|
escape_xml(body)
|
||||||
|
);
|
||||||
|
|
||||||
|
let xml_doc = XmlDocument::new()?;
|
||||||
|
xml_doc.LoadXml(&HSTRING::from(toast_xml))?;
|
||||||
|
|
||||||
|
// Create the toast notification
|
||||||
|
let toast = ToastNotification::CreateToastNotification(&xml_doc)?;
|
||||||
|
|
||||||
|
// Create a toast notifier with an application ID
|
||||||
|
let notifier =
|
||||||
|
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?;
|
||||||
|
|
||||||
|
// Show the notification
|
||||||
|
notifier.Show(&toast)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn escape_xml(text: &str) -> String {
|
||||||
|
text.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub for non-Windows platforms
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
#[command]
|
||||||
|
pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> {
|
||||||
|
Err("Windows toast notifications are only available on Windows".to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
use tauri::command;
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
|
||||||
|
// Method 1: Try Windows 10/11 toast notification using PowerShell
|
||||||
|
let toast_command = format!(
|
||||||
|
r#"
|
||||||
|
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||||
|
$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
|
||||||
|
$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
|
||||||
|
|
||||||
|
$APP_ID = 'Hikari Desktop'
|
||||||
|
|
||||||
|
$template = @"
|
||||||
|
<toast>
|
||||||
|
<visual>
|
||||||
|
<binding template="ToastGeneric">
|
||||||
|
<text>{0}</text>
|
||||||
|
<text>{1}</text>
|
||||||
|
</binding>
|
||||||
|
</visual>
|
||||||
|
</toast>
|
||||||
|
"@
|
||||||
|
|
||||||
|
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||||
|
$xml.LoadXml($template -f ('{0}' -replace "'", "''"), ('{1}' -replace "'", "''"))
|
||||||
|
|
||||||
|
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
||||||
|
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID)
|
||||||
|
$notifier.Show($toast)
|
||||||
|
"#,
|
||||||
|
title.replace("'", "''").replace("\"", "\\\""),
|
||||||
|
body.replace("'", "''").replace("\"", "\\\"")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try PowerShell.exe through WSL
|
||||||
|
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
|
||||||
|
.arg("-NoProfile")
|
||||||
|
.arg("-ExecutionPolicy")
|
||||||
|
.arg("Bypass")
|
||||||
|
.arg("-WindowStyle")
|
||||||
|
.arg("Hidden")
|
||||||
|
.arg("-Command")
|
||||||
|
.arg(&toast_command)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(result) => {
|
||||||
|
if result.status.success() {
|
||||||
|
println!("WSL notification sent successfully");
|
||||||
|
return Ok(());
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||||
|
println!("PowerShell toast failed: {}", stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Failed to run PowerShell: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip msg.exe as it creates alert boxes
|
||||||
|
// Method 2 removed
|
||||||
|
|
||||||
|
// Method 3: Try wsl-notify-send if available
|
||||||
|
let notify_result = Command::new("wsl-notify-send")
|
||||||
|
.arg("--appId")
|
||||||
|
.arg("HikariDesktop")
|
||||||
|
.arg("--category")
|
||||||
|
.arg(&title)
|
||||||
|
.arg(&body)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
if let Ok(result) = notify_result {
|
||||||
|
if result.status.success() {
|
||||||
|
println!("Notification sent via wsl-notify-send");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all methods fail, return an error
|
||||||
|
Err("All WSL notification methods failed".to_string())
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "hikari-desktop",
|
"productName": "hikari-desktop",
|
||||||
"version": "0.1.0",
|
"version": "1.1.1",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
@@ -22,6 +22,12 @@
|
|||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
|
},
|
||||||
|
"trayIcon": {
|
||||||
|
"id": "main",
|
||||||
|
"iconPath": "icons/32x32.png",
|
||||||
|
"iconAsTemplate": false,
|
||||||
|
"tooltip": "Hikari - Claude Code Assistant"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -1,14 +1,151 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root,
|
||||||
|
[data-theme="dark"] {
|
||||||
--bg-primary: #1a1a2e;
|
--bg-primary: #1a1a2e;
|
||||||
--bg-secondary: #16213e;
|
--bg-secondary: #16213e;
|
||||||
--bg-terminal: #0f0f1a;
|
--bg-terminal: #0f0f1a;
|
||||||
|
--bg-hover: #2a2a4a;
|
||||||
|
--bg-code: #1e1e2e;
|
||||||
--accent-primary: #e94560;
|
--accent-primary: #e94560;
|
||||||
--accent-secondary: #ff6b9d;
|
--accent-secondary: #ff6b9d;
|
||||||
--text-primary: #ffffff;
|
--text-primary: #ffffff;
|
||||||
--text-secondary: #a0a0a0;
|
--text-secondary: #a0a0a0;
|
||||||
|
--text-tertiary: #6b7280;
|
||||||
--border-color: #2a2a4a;
|
--border-color: #2a2a4a;
|
||||||
|
|
||||||
|
/* Trans pride colors */
|
||||||
|
--trans-blue: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors */
|
||||||
|
--terminal-user: #22d3ee;
|
||||||
|
--terminal-tool: #c084fc;
|
||||||
|
--terminal-tool-name: #ddd6fe;
|
||||||
|
--terminal-error: #f87171;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (dark) */
|
||||||
|
--hljs-keyword: #f472b6;
|
||||||
|
--hljs-string: #a3e635;
|
||||||
|
--hljs-number: #fbbf24;
|
||||||
|
--hljs-comment: #6b7280;
|
||||||
|
--hljs-function: #c084fc;
|
||||||
|
--hljs-type: #22d3ee;
|
||||||
|
--hljs-variable: #fb923c;
|
||||||
|
--hljs-meta: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-primary: #f8f9fa;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-terminal: #f1f3f4;
|
||||||
|
--bg-hover: #e8e8e8;
|
||||||
|
--bg-code: #f5f5f5;
|
||||||
|
--accent-primary: #e94560;
|
||||||
|
--accent-secondary: #ff6b9d;
|
||||||
|
--text-primary: #1a1a2e;
|
||||||
|
--text-secondary: #5a5a7a;
|
||||||
|
--text-tertiary: #9ca3af;
|
||||||
|
--border-color: #d0d0e0;
|
||||||
|
|
||||||
|
/* Trans pride colors */
|
||||||
|
--trans-blue: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors */
|
||||||
|
--terminal-user: #0891b2;
|
||||||
|
--terminal-tool: #7c3aed;
|
||||||
|
--terminal-tool-name: #8b5cf6;
|
||||||
|
--terminal-error: #dc2626;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (light) */
|
||||||
|
--hljs-keyword: #d946ef;
|
||||||
|
--hljs-string: #16a34a;
|
||||||
|
--hljs-number: #d97706;
|
||||||
|
--hljs-comment: #9ca3af;
|
||||||
|
--hljs-function: #7c3aed;
|
||||||
|
--hljs-type: #0891b2;
|
||||||
|
--hljs-variable: #ea580c;
|
||||||
|
--hljs-meta: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="high-contrast"] {
|
||||||
|
--bg-primary: #000000;
|
||||||
|
--bg-secondary: #0a0a0a;
|
||||||
|
--bg-terminal: #000000;
|
||||||
|
--bg-hover: #1a1a1a;
|
||||||
|
--bg-code: #0a0a0a;
|
||||||
|
--accent-primary: #ff4d6d;
|
||||||
|
--accent-secondary: #ff85a1;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #e0e0e0;
|
||||||
|
--text-tertiary: #b0b0b0;
|
||||||
|
--border-color: #ffffff;
|
||||||
|
|
||||||
|
/* Trans pride colors (high contrast) */
|
||||||
|
--trans-blue: #00d4ff;
|
||||||
|
--trans-pink: #ff99cc;
|
||||||
|
--trans-white: #ffffff;
|
||||||
|
--trans-gradient: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 50%,
|
||||||
|
var(--trans-white) 100%
|
||||||
|
);
|
||||||
|
--trans-gradient-vibrant: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--trans-blue) 0%,
|
||||||
|
var(--trans-pink) 35%,
|
||||||
|
var(--trans-white) 50%,
|
||||||
|
var(--trans-pink) 65%,
|
||||||
|
var(--trans-blue) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Terminal specific colors - bright and saturated */
|
||||||
|
--terminal-user: #00ffff;
|
||||||
|
--terminal-tool: #ff00ff;
|
||||||
|
--terminal-tool-name: #ffaaff;
|
||||||
|
--terminal-error: #ff5555;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (high contrast) */
|
||||||
|
--hljs-keyword: #ff66ff;
|
||||||
|
--hljs-string: #66ff66;
|
||||||
|
--hljs-number: #ffff00;
|
||||||
|
--hljs-comment: #aaaaaa;
|
||||||
|
--hljs-function: #ff99ff;
|
||||||
|
--hljs-type: #00ffff;
|
||||||
|
--hljs-variable: #ffaa00;
|
||||||
|
--hljs-meta: #cccccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -51,3 +188,52 @@ body {
|
|||||||
background: var(--accent-primary);
|
background: var(--accent-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Trans gradient button - primary action buttons */
|
||||||
|
.btn-trans-gradient {
|
||||||
|
background: var(--trans-gradient-vibrant) !important;
|
||||||
|
border: none !important;
|
||||||
|
color: #1a1a2e !important;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-trans-gradient:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(91, 206, 250, 0.4),
|
||||||
|
0 0 30px rgba(245, 169, 184, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-trans-gradient:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trans gradient focus border for inputs */
|
||||||
|
.input-trans-focus {
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-trans-focus:focus {
|
||||||
|
border-color: var(--trans-pink) !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px var(--trans-blue),
|
||||||
|
0 0 12px rgba(91, 206, 250, 0.3),
|
||||||
|
0 0 20px rgba(245, 169, 184, 0.2) !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trans gradient hover for icon buttons */
|
||||||
|
.icon-trans-hover {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-trans-hover:hover {
|
||||||
|
color: var(--trans-pink) !important;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(91, 206, 250, 0.5))
|
||||||
|
drop-shadow(0 0 10px rgba(245, 169, 184, 0.4));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
slashCommands,
|
||||||
|
parseSlashCommand,
|
||||||
|
getMatchingCommands,
|
||||||
|
isSlashCommand,
|
||||||
|
type SlashCommand,
|
||||||
|
} from "./slashCommands";
|
||||||
|
|
||||||
|
// Mock all external dependencies
|
||||||
|
vi.mock("svelte/store", () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
|
invoke: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/claude", () => ({
|
||||||
|
claudeStore: {
|
||||||
|
addLine: vi.fn(),
|
||||||
|
clearTerminal: vi.fn(),
|
||||||
|
activeConversationId: { subscribe: vi.fn() },
|
||||||
|
currentWorkingDirectory: { subscribe: vi.fn() },
|
||||||
|
setWorkingDirectory: vi.fn(),
|
||||||
|
getConversationHistory: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/character", () => ({
|
||||||
|
characterState: {
|
||||||
|
setState: vi.fn(),
|
||||||
|
setTemporaryState: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/tauri", () => ({
|
||||||
|
setSkipNextGreeting: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/search", () => ({
|
||||||
|
searchState: {
|
||||||
|
setQuery: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("slashCommands", () => {
|
||||||
|
describe("slashCommands array", () => {
|
||||||
|
it("contains expected commands", () => {
|
||||||
|
const commandNames = slashCommands.map((cmd) => cmd.name);
|
||||||
|
expect(commandNames).toContain("cd");
|
||||||
|
expect(commandNames).toContain("clear");
|
||||||
|
expect(commandNames).toContain("new");
|
||||||
|
expect(commandNames).toContain("help");
|
||||||
|
expect(commandNames).toContain("search");
|
||||||
|
expect(commandNames).toContain("summarise");
|
||||||
|
expect(commandNames).toContain("skill");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has 7 commands total", () => {
|
||||||
|
expect(slashCommands.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each command has required properties", () => {
|
||||||
|
slashCommands.forEach((cmd) => {
|
||||||
|
expect(cmd.name).toBeDefined();
|
||||||
|
expect(typeof cmd.name).toBe("string");
|
||||||
|
expect(cmd.name.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(cmd.description).toBeDefined();
|
||||||
|
expect(typeof cmd.description).toBe("string");
|
||||||
|
expect(cmd.description.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(cmd.usage).toBeDefined();
|
||||||
|
expect(typeof cmd.usage).toBe("string");
|
||||||
|
expect(cmd.usage.startsWith("/")).toBe(true);
|
||||||
|
|
||||||
|
expect(cmd.execute).toBeDefined();
|
||||||
|
expect(typeof cmd.execute).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cd command has correct metadata", () => {
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd");
|
||||||
|
expect(cdCmd).toBeDefined();
|
||||||
|
expect(cdCmd!.description).toBe("Change the working directory");
|
||||||
|
expect(cdCmd!.usage).toBe("/cd <path>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear command has correct metadata", () => {
|
||||||
|
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear");
|
||||||
|
expect(clearCmd).toBeDefined();
|
||||||
|
expect(clearCmd!.description).toBe("Clear the terminal display (keeps conversation context)");
|
||||||
|
expect(clearCmd!.usage).toBe("/clear");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("new command has correct metadata", () => {
|
||||||
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new");
|
||||||
|
expect(newCmd).toBeDefined();
|
||||||
|
expect(newCmd!.description).toBe("Start a fresh conversation (resets context)");
|
||||||
|
expect(newCmd!.usage).toBe("/new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("help command has correct metadata", () => {
|
||||||
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help");
|
||||||
|
expect(helpCmd).toBeDefined();
|
||||||
|
expect(helpCmd!.description).toBe("Show available slash commands");
|
||||||
|
expect(helpCmd!.usage).toBe("/help");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search command has correct metadata", () => {
|
||||||
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search");
|
||||||
|
expect(searchCmd).toBeDefined();
|
||||||
|
expect(searchCmd!.description).toBe("Search within the conversation (use /search to clear)");
|
||||||
|
expect(searchCmd!.usage).toBe("/search [query]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarise command has correct metadata", () => {
|
||||||
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise");
|
||||||
|
expect(summariseCmd).toBeDefined();
|
||||||
|
expect(summariseCmd!.description).toBe("Get a summary of the entire conversation");
|
||||||
|
expect(summariseCmd!.usage).toBe("/summarise");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skill command has correct metadata", () => {
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill");
|
||||||
|
expect(skillCmd).toBeDefined();
|
||||||
|
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
||||||
|
expect(skillCmd!.usage).toBe("/skill [name] [data]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseSlashCommand", () => {
|
||||||
|
it("returns null for non-slash input", () => {
|
||||||
|
const result = parseSlashCommand("hello world");
|
||||||
|
expect(result.command).toBeNull();
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty string", () => {
|
||||||
|
const result = parseSlashCommand("");
|
||||||
|
expect(result.command).toBeNull();
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for whitespace only", () => {
|
||||||
|
const result = parseSlashCommand(" ");
|
||||||
|
expect(result.command).toBeNull();
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /cd command without args", () => {
|
||||||
|
const result = parseSlashCommand("/cd");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("cd");
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /cd command with path argument", () => {
|
||||||
|
const result = parseSlashCommand("/cd /home/naomi/code");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("cd");
|
||||||
|
expect(result.args).toBe("/home/naomi/code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /clear command", () => {
|
||||||
|
const result = parseSlashCommand("/clear");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("clear");
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /new command", () => {
|
||||||
|
const result = parseSlashCommand("/new");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("new");
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /help command", () => {
|
||||||
|
const result = parseSlashCommand("/help");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("help");
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /search command with query", () => {
|
||||||
|
const result = parseSlashCommand("/search hello world");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("search");
|
||||||
|
expect(result.args).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /search command without query", () => {
|
||||||
|
const result = parseSlashCommand("/search");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("search");
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /summarise command", () => {
|
||||||
|
const result = parseSlashCommand("/summarise");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("summarise");
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /skill command with name and data", () => {
|
||||||
|
const result = parseSlashCommand("/skill onboard-mentee john@example.com");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("skill");
|
||||||
|
expect(result.args).toBe("onboard-mentee john@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /skill command with name only", () => {
|
||||||
|
const result = parseSlashCommand("/skill onboard-mentee");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("skill");
|
||||||
|
expect(result.args).toBe("onboard-mentee");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses /skill command without arguments", () => {
|
||||||
|
const result = parseSlashCommand("/skill");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("skill");
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown command", () => {
|
||||||
|
const result = parseSlashCommand("/unknown");
|
||||||
|
expect(result.command).toBeNull();
|
||||||
|
expect(result.args).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case insensitive for command names", () => {
|
||||||
|
const result1 = parseSlashCommand("/CD /path");
|
||||||
|
expect(result1.command).not.toBeNull();
|
||||||
|
expect(result1.command!.name).toBe("cd");
|
||||||
|
|
||||||
|
const result2 = parseSlashCommand("/CLEAR");
|
||||||
|
expect(result2.command).not.toBeNull();
|
||||||
|
expect(result2.command!.name).toBe("clear");
|
||||||
|
|
||||||
|
const result3 = parseSlashCommand("/Help");
|
||||||
|
expect(result3.command).not.toBeNull();
|
||||||
|
expect(result3.command!.name).toBe("help");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles leading whitespace", () => {
|
||||||
|
const result = parseSlashCommand(" /cd /path");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("cd");
|
||||||
|
expect(result.args).toBe("/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles trailing whitespace", () => {
|
||||||
|
const result = parseSlashCommand("/cd /path ");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("cd");
|
||||||
|
expect(result.args).toBe("/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple spaces between args", () => {
|
||||||
|
const result = parseSlashCommand("/search hello world");
|
||||||
|
expect(result.command).not.toBeNull();
|
||||||
|
expect(result.command!.name).toBe("search");
|
||||||
|
expect(result.args).toBe("hello world");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMatchingCommands", () => {
|
||||||
|
it("returns empty array for non-slash input", () => {
|
||||||
|
const result = getMatchingCommands("hello");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for empty string", () => {
|
||||||
|
const result = getMatchingCommands("");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all commands for just slash", () => {
|
||||||
|
const result = getMatchingCommands("/");
|
||||||
|
expect(result.length).toBe(slashCommands.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns matching commands for partial input", () => {
|
||||||
|
const result = getMatchingCommands("/c");
|
||||||
|
const names = result.map((cmd) => cmd.name);
|
||||||
|
expect(names).toContain("cd");
|
||||||
|
expect(names).toContain("clear");
|
||||||
|
expect(names).not.toContain("help");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns single command for exact match", () => {
|
||||||
|
const result = getMatchingCommands("/cd");
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].name).toBe("cd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns single command for partial unique match", () => {
|
||||||
|
const result = getMatchingCommands("/cl");
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].name).toBe("clear");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns matching commands for /s prefix", () => {
|
||||||
|
const result = getMatchingCommands("/s");
|
||||||
|
const names = result.map((cmd) => cmd.name);
|
||||||
|
expect(names).toContain("search");
|
||||||
|
expect(names).toContain("summarise");
|
||||||
|
expect(names).toContain("skill");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case insensitive", () => {
|
||||||
|
const result1 = getMatchingCommands("/C");
|
||||||
|
const result2 = getMatchingCommands("/c");
|
||||||
|
expect(result1.length).toBe(result2.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for no matches", () => {
|
||||||
|
const result = getMatchingCommands("/xyz");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles whitespace correctly", () => {
|
||||||
|
const result = getMatchingCommands(" /c");
|
||||||
|
const names = result.map((cmd) => cmd.name);
|
||||||
|
expect(names).toContain("cd");
|
||||||
|
expect(names).toContain("clear");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns command for full command name", () => {
|
||||||
|
const result = getMatchingCommands("/help");
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].name).toBe("help");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns command for /new", () => {
|
||||||
|
const result = getMatchingCommands("/n");
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].name).toBe("new");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSlashCommand", () => {
|
||||||
|
it("returns true for input starting with slash", () => {
|
||||||
|
expect(isSlashCommand("/cd")).toBe(true);
|
||||||
|
expect(isSlashCommand("/")).toBe(true);
|
||||||
|
expect(isSlashCommand("/help")).toBe(true);
|
||||||
|
expect(isSlashCommand("/unknown")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for non-slash input", () => {
|
||||||
|
expect(isSlashCommand("hello")).toBe(false);
|
||||||
|
expect(isSlashCommand("")).toBe(false);
|
||||||
|
expect(isSlashCommand("cd")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles whitespace correctly", () => {
|
||||||
|
expect(isSlashCommand(" /cd")).toBe(true);
|
||||||
|
expect(isSlashCommand(" hello")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for slash in middle of string", () => {
|
||||||
|
expect(isSlashCommand("hello/world")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SlashCommand interface", () => {
|
||||||
|
it("can create a valid slash command object", () => {
|
||||||
|
const testCommand: SlashCommand = {
|
||||||
|
name: "test",
|
||||||
|
description: "A test command",
|
||||||
|
usage: "/test [arg]",
|
||||||
|
execute: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(testCommand.name).toBe("test");
|
||||||
|
expect(testCommand.description).toBe("A test command");
|
||||||
|
expect(testCommand.usage).toBe("/test [arg]");
|
||||||
|
expect(typeof testCommand.execute).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("execute can be async function", () => {
|
||||||
|
const asyncCommand: SlashCommand = {
|
||||||
|
name: "async",
|
||||||
|
description: "An async command",
|
||||||
|
usage: "/async",
|
||||||
|
execute: async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(asyncCommand.execute("")).toBeInstanceOf(Promise);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("execute can be sync function", () => {
|
||||||
|
const syncCommand: SlashCommand = {
|
||||||
|
name: "sync",
|
||||||
|
description: "A sync command",
|
||||||
|
usage: "/sync",
|
||||||
|
execute: () => {
|
||||||
|
// Synchronous execution
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = syncCommand.execute("");
|
||||||
|
// Sync function returns undefined, not a Promise
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { get } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import { setSkipNextGreeting } from "$lib/tauri";
|
||||||
|
import { searchState } from "$lib/stores/search";
|
||||||
|
|
||||||
|
export interface SlashCommand {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
usage: string;
|
||||||
|
execute: (args: string) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeDirectory(path: string): Promise<void> {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.trim()) {
|
||||||
|
const currentDir = get(claudeStore.currentWorkingDirectory);
|
||||||
|
claudeStore.addLine("system", `Current directory: ${currentDir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
characterState.setState("thinking");
|
||||||
|
claudeStore.addLine("system", `Changing directory to: ${path}`);
|
||||||
|
|
||||||
|
const currentDir = get(claudeStore.currentWorkingDirectory);
|
||||||
|
const validatedPath = await invoke<string>("validate_directory", { path, currentDir });
|
||||||
|
|
||||||
|
// Capture conversation history before disconnecting
|
||||||
|
const conversationHistory = claudeStore.getConversationHistory();
|
||||||
|
|
||||||
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
|
// Wait for clean shutdown
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
claudeStore.setWorkingDirectory(validatedPath);
|
||||||
|
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
|
await invoke("start_claude", {
|
||||||
|
conversationId,
|
||||||
|
options: {
|
||||||
|
working_dir: validatedPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for connection to establish
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Restore context if there was conversation history
|
||||||
|
if (conversationHistory) {
|
||||||
|
const contextMessage = `[CONTEXT RESTORATION]
|
||||||
|
I just changed the working directory from ${currentDir} to ${validatedPath}. Here's our conversation so far:
|
||||||
|
|
||||||
|
${conversationHistory}
|
||||||
|
|
||||||
|
Please continue where we left off. You are now operating in the new directory.`;
|
||||||
|
|
||||||
|
await invoke("send_prompt", {
|
||||||
|
conversationId,
|
||||||
|
message: contextMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeStore.addLine("system", `Changed directory to: ${validatedPath}`);
|
||||||
|
characterState.setState("idle");
|
||||||
|
} catch (error) {
|
||||||
|
claudeStore.addLine("error", `Failed to change directory: ${error}`);
|
||||||
|
characterState.setTemporaryState("error", 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startNewConversation(): Promise<void> {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workingDir = await invoke<string>("get_working_directory", {
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.addLine("system", "Starting new conversation...");
|
||||||
|
characterState.setState("thinking");
|
||||||
|
|
||||||
|
await invoke("interrupt_claude", { conversationId });
|
||||||
|
|
||||||
|
claudeStore.clearTerminal();
|
||||||
|
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
|
await invoke("start_claude", {
|
||||||
|
conversationId,
|
||||||
|
options: {
|
||||||
|
working_dir: workingDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.addLine("system", "New conversation started!");
|
||||||
|
characterState.setState("idle");
|
||||||
|
} catch (error) {
|
||||||
|
claudeStore.addLine("error", `Failed to start new conversation: ${error}`);
|
||||||
|
characterState.setTemporaryState("error", 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const slashCommands: SlashCommand[] = [
|
||||||
|
{
|
||||||
|
name: "cd",
|
||||||
|
description: "Change the working directory",
|
||||||
|
usage: "/cd <path>",
|
||||||
|
execute: changeDirectory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear",
|
||||||
|
description: "Clear the terminal display (keeps conversation context)",
|
||||||
|
usage: "/clear",
|
||||||
|
execute: () => {
|
||||||
|
claudeStore.clearTerminal();
|
||||||
|
claudeStore.addLine("system", "Terminal cleared");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new",
|
||||||
|
description: "Start a fresh conversation (resets context)",
|
||||||
|
usage: "/new",
|
||||||
|
execute: startNewConversation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "help",
|
||||||
|
description: "Show available slash commands",
|
||||||
|
usage: "/help",
|
||||||
|
execute: () => {
|
||||||
|
const helpText = slashCommands
|
||||||
|
.map((cmd) => ` ${cmd.usage.padEnd(12)} - ${cmd.description}`)
|
||||||
|
.join("\n");
|
||||||
|
claudeStore.addLine("system", `Available commands:\n${helpText}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search",
|
||||||
|
description: "Search within the conversation (use /search to clear)",
|
||||||
|
usage: "/search [query]",
|
||||||
|
execute: (args: string) => {
|
||||||
|
if (!args.trim()) {
|
||||||
|
searchState.clear();
|
||||||
|
claudeStore.addLine("system", "Search cleared");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchState.setQuery(args.trim());
|
||||||
|
claudeStore.addLine("system", `Searching for: "${args.trim()}"`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "summarise",
|
||||||
|
description: "Get a summary of the entire conversation",
|
||||||
|
usage: "/summarise",
|
||||||
|
execute: async () => {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
claudeStore.addLine("system", "Requesting conversation summary...");
|
||||||
|
await invoke("send_prompt", {
|
||||||
|
conversationId,
|
||||||
|
message:
|
||||||
|
"Please provide a comprehensive summary of our entire conversation so far, including the key topics we've discussed, decisions made, and any important context.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
claudeStore.addLine("error", `Failed to request summary: ${error}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skill",
|
||||||
|
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
||||||
|
usage: "/skill [name] [data]",
|
||||||
|
execute: async (args: string) => {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
claudeStore.addLine("error", "No active conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = args.trim().split(/\s+/);
|
||||||
|
const skillName = parts[0];
|
||||||
|
const skillData = parts.slice(1).join(" ");
|
||||||
|
|
||||||
|
// If no skill name provided, list available skills
|
||||||
|
if (!skillName) {
|
||||||
|
try {
|
||||||
|
const skills = await invoke<string[]>("list_skills");
|
||||||
|
if (skills.length === 0) {
|
||||||
|
claudeStore.addLine(
|
||||||
|
"system",
|
||||||
|
"No skills found in ~/.claude/skills/\nCreate a skill by adding a folder with a SKILL.md file."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const skillList = skills.map((s) => ` • ${s}`).join("\n");
|
||||||
|
claudeStore.addLine(
|
||||||
|
"system",
|
||||||
|
`Available skills:\n${skillList}\n\nUsage: /skill <skill-name> [data]`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
claudeStore.addLine("error", `Failed to list skills: ${error}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
claudeStore.addLine("system", `Invoking skill: ${skillName}`);
|
||||||
|
characterState.setState("thinking");
|
||||||
|
|
||||||
|
const message = skillData
|
||||||
|
? `Please run the /${skillName} skill with the following data:\n\n${skillData}`
|
||||||
|
: `Please run the /${skillName} skill.`;
|
||||||
|
|
||||||
|
await invoke("send_prompt", {
|
||||||
|
conversationId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
claudeStore.addLine("error", `Failed to invoke skill: ${error}`);
|
||||||
|
characterState.setTemporaryState("error", 3000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function parseSlashCommand(input: string): {
|
||||||
|
command: SlashCommand | null;
|
||||||
|
args: string;
|
||||||
|
} {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
if (!trimmed.startsWith("/")) {
|
||||||
|
return { command: null, args: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = trimmed.slice(1).split(/\s+/);
|
||||||
|
const commandName = parts[0]?.toLowerCase();
|
||||||
|
const args = parts.slice(1).join(" ");
|
||||||
|
|
||||||
|
const command = slashCommands.find((cmd) => cmd.name.toLowerCase() === commandName);
|
||||||
|
|
||||||
|
return { command: command || null, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMatchingCommands(input: string): SlashCommand[] {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
if (!trimmed.startsWith("/")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const partial = trimmed.slice(1).toLowerCase();
|
||||||
|
|
||||||
|
if (partial === "") {
|
||||||
|
return slashCommands;
|
||||||
|
}
|
||||||
|
|
||||||
|
return slashCommands.filter((cmd) => cmd.name.toLowerCase().startsWith(partial));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSlashCommand(input: string): boolean {
|
||||||
|
return input.trim().startsWith("/");
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose }: Props = $props();
|
||||||
|
|
||||||
|
let appVersion = $state("");
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
appVersion = await getVersion();
|
||||||
|
});
|
||||||
|
|
||||||
|
const links = {
|
||||||
|
source: "https://git.nhcarrigan.com/nhcarrigan/hikari-desktop",
|
||||||
|
discord: "https://chat.nhcarrigan.com",
|
||||||
|
website: "https://nhcarrigan.com",
|
||||||
|
license: "https://docs.nhcarrigan.com/legal/license/",
|
||||||
|
changelog: "https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/releases",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onclick={onClose}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full p-6"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="about-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 id="about-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
|
About Hikari Desktop
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-[var(--text-primary)] mb-2">What is Hikari Desktop?</h3>
|
||||||
|
<p class="text-[var(--text-secondary)]">
|
||||||
|
Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your
|
||||||
|
desktop. Built with love using Tauri, Svelte, and Rust for a fast, native experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-[var(--text-primary)] mb-2">Version</h3>
|
||||||
|
<p class="text-[var(--text-secondary)] mb-1">
|
||||||
|
{appVersion || "Loading..."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onclick={() => openUrl(links.changelog)}
|
||||||
|
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||||
|
>
|
||||||
|
View Changelog
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-[var(--text-primary)] mb-2">Source Code</h3>
|
||||||
|
<button
|
||||||
|
onclick={() => openUrl(links.source)}
|
||||||
|
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||||
|
>
|
||||||
|
View on Git
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-[var(--text-primary)] mb-2">Support & Community</h3>
|
||||||
|
<p class="text-[var(--text-secondary)] mb-1">Found a bug or have a suggestion?</p>
|
||||||
|
<button
|
||||||
|
onclick={() => openUrl(links.discord)}
|
||||||
|
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||||
|
>
|
||||||
|
Join our Discord
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-[var(--text-primary)] mb-2">Built with 💕 by</h3>
|
||||||
|
<button
|
||||||
|
onclick={() => openUrl(links.website)}
|
||||||
|
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||||
|
>
|
||||||
|
Naomi Carrigan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-[var(--text-primary)] mb-2">License</h3>
|
||||||
|
<p class="text-[var(--text-secondary)] mb-1">
|
||||||
|
This project is open source and available under our license terms.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onclick={() => openUrl(links.license)}
|
||||||
|
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||||
|
>
|
||||||
|
View License
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 mt-4 border-t border-[var(--border-color)]">
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] text-center">
|
||||||
|
Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Ensure the panel appears above other content */
|
||||||
|
[role="dialog"] {
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||||
|
|
||||||
|
let achievements = $state<AchievementUnlockedEvent[]>([]);
|
||||||
|
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
|
||||||
|
let showNotification = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
|
||||||
|
const setupListener = async () => {
|
||||||
|
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
||||||
|
achievements.push(event.payload);
|
||||||
|
if (!showNotification) {
|
||||||
|
showNext();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setupListener();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlisten) {
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function showNext() {
|
||||||
|
if (achievements.length > 0) {
|
||||||
|
currentAchievement = achievements.shift() || null;
|
||||||
|
showNotification = true;
|
||||||
|
|
||||||
|
// Auto-hide after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
showNotification = false;
|
||||||
|
// Show next achievement after animation completes
|
||||||
|
setTimeout(() => showNext(), 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
showNotification = false;
|
||||||
|
// Show next achievement after animation completes
|
||||||
|
setTimeout(() => showNext(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRarityColor(rarity: string): string {
|
||||||
|
switch (rarity) {
|
||||||
|
case "legendary":
|
||||||
|
return "from-yellow-400 to-orange-500";
|
||||||
|
case "epic":
|
||||||
|
return "from-purple-400 to-pink-500";
|
||||||
|
case "rare":
|
||||||
|
return "from-blue-400 to-indigo-500";
|
||||||
|
default:
|
||||||
|
return "from-green-400 to-emerald-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAchievementRarity(id: string): string {
|
||||||
|
// Determine rarity based on achievement ID
|
||||||
|
if (id === "TokenMaster") return "legendary";
|
||||||
|
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"BlossomingCoder",
|
||||||
|
"CodeWizard",
|
||||||
|
"MasterBuilder",
|
||||||
|
"EnduranceChamp",
|
||||||
|
"DeepDive",
|
||||||
|
"CreativeCoder",
|
||||||
|
].includes(id)
|
||||||
|
)
|
||||||
|
return "rare";
|
||||||
|
return "common";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showNotification && currentAchievement}
|
||||||
|
<div
|
||||||
|
class="fixed top-20 right-4 z-50 max-w-sm"
|
||||||
|
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
|
||||||
|
out:fade={{ duration: 300 }}
|
||||||
|
>
|
||||||
|
<!-- Backdrop with animated gradient border -->
|
||||||
|
<div class="relative p-[2px] rounded-lg overflow-hidden">
|
||||||
|
<!-- Animated gradient border -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
|
||||||
|
getAchievementRarity(currentAchievement.achievement.id)
|
||||||
|
)} animate-pulse"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Main notification content -->
|
||||||
|
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
||||||
|
<button
|
||||||
|
onclick={dismiss}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && dismiss()}
|
||||||
|
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<!-- Icon with animated sparkles -->
|
||||||
|
<div class="relative flex-shrink-0">
|
||||||
|
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
|
||||||
|
|
||||||
|
<!-- Sparkle animations -->
|
||||||
|
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping">✨</div>
|
||||||
|
<div
|
||||||
|
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
|
||||||
|
>
|
||||||
|
✨
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
|
||||||
|
✨
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text content -->
|
||||||
|
<div class="flex-1 min-w-0 pt-1">
|
||||||
|
<h3
|
||||||
|
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
Achievement Unlocked!
|
||||||
|
</h3>
|
||||||
|
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
||||||
|
{currentAchievement.achievement.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{currentAchievement.achievement.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Rarity badge -->
|
||||||
|
<div class="mt-2 inline-flex items-center">
|
||||||
|
<span
|
||||||
|
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
|
||||||
|
getAchievementRarity(currentAchievement.achievement.id)
|
||||||
|
)} text-white capitalize"
|
||||||
|
>
|
||||||
|
{getAchievementRarity(currentAchievement.achievement.id)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Celebration confetti effect (CSS only) -->
|
||||||
|
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||||
|
{#each Array(10) as _ (_)}
|
||||||
|
<div
|
||||||
|
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
||||||
|
getAchievementRarity(currentAchievement.achievement.id)
|
||||||
|
)} rounded-full animate-fall"
|
||||||
|
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
|
||||||
|
2}s; animation-duration: {2 + Math.random() * 2}s;"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes fall {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-20px) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(400px) rotate(720deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fall {
|
||||||
|
animation: fall linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-200 {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-400 {
|
||||||
|
animation-delay: 400ms;
|
||||||
|
}
|
||||||
|
</style>
|
||||||