Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73d66e9ae4 | |||
| 42c3b4ee83 | |||
| 8a19f35922 | |||
| 4f02747064 | |||
| 43f92f227c | |||
| c5e0d5302c | |||
| ff50b28641 | |||
| 0ac52c8c8d | |||
| 0a73d2238c | |||
| c34720346e | |||
| 02cc8bd0d5 | |||
| 87cf633564 | |||
| a30b3a282c | |||
| 02987f1a17 | |||
| 4a8a1d564a | |||
| 89cc655fd1 | |||
| ce97c51cd8 | |||
| b1a45ed00e | |||
| 457722dc3a |
@@ -49,13 +49,13 @@ jobs:
|
||||
- name: Run Svelte Check
|
||||
run: pnpm check
|
||||
|
||||
- name: Run frontend tests with coverage
|
||||
run: pnpm test:coverage
|
||||
- name: Run frontend tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, llvm-tools-preview
|
||||
components: clippy
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -68,16 +68,13 @@ jobs:
|
||||
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
|
||||
- name: Run Rust tests
|
||||
working-directory: src-tauri
|
||||
run: cargo llvm-cov --fail-under-lines 50
|
||||
run: cargo test
|
||||
|
||||
build-linux:
|
||||
name: Build Linux
|
||||
|
||||
@@ -8,6 +8,3 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Coverage reports
|
||||
/coverage
|
||||
|
||||
@@ -1,29 +1 @@
|
||||
# hikari-desktop
|
||||
|
||||
Desktop companion application featuring Hikari.
|
||||
|
||||
## Live Version
|
||||
|
||||
This page is currently deployed. [View the live website.](https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/releases)
|
||||
|
||||
## Feedback and Bugs
|
||||
|
||||
If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
|
||||
|
||||
## Contributing
|
||||
|
||||
If you would like to contribute to the project, you may create a Pull Request containing your proposed changes and we will review it as soon as we are able! Please review our [contributing guidelines](CONTRIBUTING.md) first.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Before interacting with our community, please read our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## License
|
||||
|
||||
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
|
||||
|
||||
Copyright held by Naomi Carrigan.
|
||||
|
||||
## Contact
|
||||
|
||||
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`
|
||||
tem
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Hikari Desktop v0.3.0 Release Notes
|
||||
|
||||
## New Features
|
||||
|
||||
### AskUserQuestion Tool Support (#51)
|
||||
|
||||
- Claude can now ask you questions with multiple choice options during conversations
|
||||
- A dedicated modal appears with answer choices and support for custom responses
|
||||
- Answers are seamlessly integrated back into the conversation context
|
||||
|
||||
### Slash Commands
|
||||
|
||||
- `/cd <path>` - Change the working directory while preserving conversation context (#55)
|
||||
- `/search <query>` - Search and highlight matches within the conversation (#32)
|
||||
- `/skill <name> [data]` - Invoke Claude Code skills from `~/.claude/skills/` (#57)
|
||||
- `/new`, `/clear`, `/rename`, `/help` commands for conversation management (#6)
|
||||
|
||||
### Auto-Update Checker (#17)
|
||||
|
||||
- Automatically checks for new releases on startup
|
||||
- Notification appears when a newer version is available
|
||||
- Can be disabled in settings
|
||||
|
||||
### Font Size & Zoom (#19)
|
||||
|
||||
- Adjust font size with keyboard shortcuts: `Ctrl++`, `Ctrl+-`, `Ctrl+0`
|
||||
- Font size slider in settings (10-24px range)
|
||||
- Preference persists between sessions
|
||||
|
||||
### Resizable Character Panel (#10)
|
||||
|
||||
- Drag the divider between the character panel and terminal to resize
|
||||
- Panel width is saved and restored on app restart
|
||||
- Sprite now uses full height for better proportions
|
||||
|
||||
### Input History Navigation (#13)
|
||||
|
||||
- Use up/down arrows to navigate through previous messages and commands
|
||||
- Arrow keys only navigate history when input is empty - otherwise they move the cursor (#58)
|
||||
|
||||
### Keyboard Shortcuts (#21)
|
||||
|
||||
- `Ctrl+N` - New conversation
|
||||
- `Ctrl+W` - Close current tab
|
||||
- `Ctrl+Tab` / `Ctrl+Shift+Tab` - Switch between tabs
|
||||
- `Ctrl+L` - Clear conversation
|
||||
- `Ctrl+,` - Open settings
|
||||
|
||||
### Always On Top Toggle (#28)
|
||||
|
||||
- Pin the window to stay above other applications
|
||||
- Toggle in settings
|
||||
|
||||
## Improvements
|
||||
|
||||
### UI/UX
|
||||
|
||||
- Resizable chat input with drag handle (expands upward)
|
||||
- Send button properly aligned with input field
|
||||
- Markdown rendering with syntax-highlighted code blocks (#31, #33)
|
||||
- Light mode text colors improved for better readability
|
||||
- Scroll position persists per conversation tab when switching
|
||||
- Confirmation modal when closing connected tabs
|
||||
- Links in chat now open in default browser (#54)
|
||||
- Spaces allowed when renaming tabs (#52)
|
||||
|
||||
### State Management
|
||||
|
||||
- Stats (tokens, cost) persist across session changes and only reset on disconnect (#59)
|
||||
- Multiple tabs can now request permissions simultaneously without conflicts
|
||||
|
||||
## Closed Issues
|
||||
|
||||
#6, #10, #13, #17, #19, #21, #28, #31, #32, #33, #51, #52, #54, #55, #57, #58, #59
|
||||
@@ -27,6 +27,6 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/", "coverage/"],
|
||||
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/"],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "1.1.1",
|
||||
"version": "0.3.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,10 +16,6 @@
|
||||
"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 .",
|
||||
@@ -27,32 +23,6 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"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/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
@@ -62,7 +32,6 @@
|
||||
"@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"
|
||||
},
|
||||
@@ -75,7 +44,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -8,84 +8,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@codemirror/commands':
|
||||
specifier: 6.8.1
|
||||
version: 6.8.1
|
||||
'@codemirror/lang-angular':
|
||||
specifier: ^0.1.4
|
||||
version: 0.1.4
|
||||
'@codemirror/lang-cpp':
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
'@codemirror/lang-css':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1
|
||||
'@codemirror/lang-go':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
'@codemirror/lang-html':
|
||||
specifier: ^6.4.11
|
||||
version: 6.4.11
|
||||
'@codemirror/lang-java':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lang-javascript':
|
||||
specifier: ^6.2.4
|
||||
version: 6.2.4
|
||||
'@codemirror/lang-json':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lang-less':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lang-markdown':
|
||||
specifier: ^6.5.0
|
||||
version: 6.5.0
|
||||
'@codemirror/lang-php':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lang-python':
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
'@codemirror/lang-rust':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lang-sass':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lang-sql':
|
||||
specifier: ^6.10.0
|
||||
version: 6.10.0
|
||||
'@codemirror/lang-vue':
|
||||
specifier: ^0.1.3
|
||||
version: 0.1.3
|
||||
'@codemirror/lang-wast':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lang-xml':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
'@codemirror/lang-yaml':
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2
|
||||
'@codemirror/language':
|
||||
specifier: ^6.12.1
|
||||
version: 6.12.1
|
||||
'@codemirror/legacy-modes':
|
||||
specifier: ^6.5.2
|
||||
version: 6.5.2
|
||||
'@codemirror/state':
|
||||
specifier: ^6.5.4
|
||||
version: 6.5.4
|
||||
'@codemirror/theme-one-dark':
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
'@codemirror/view':
|
||||
specifier: ^6.39.11
|
||||
version: 6.39.11
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2
|
||||
version: 2.9.1
|
||||
@@ -113,9 +35,6 @@ importers:
|
||||
'@tauri-apps/plugin-store':
|
||||
specifier: ^2
|
||||
version: 2.4.2
|
||||
codemirror:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
@@ -147,9 +66,6 @@ importers:
|
||||
'@testing-library/svelte':
|
||||
specifier: ^5.3.1
|
||||
version: 5.3.1(svelte@5.46.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))
|
||||
eslint:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
@@ -214,115 +130,14 @@ packages:
|
||||
resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.28.6':
|
||||
resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.28.6':
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.28.6':
|
||||
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@codemirror/autocomplete@6.20.0':
|
||||
resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
|
||||
|
||||
'@codemirror/commands@6.8.1':
|
||||
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
|
||||
|
||||
'@codemirror/lang-angular@0.1.4':
|
||||
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
|
||||
|
||||
'@codemirror/lang-cpp@6.0.3':
|
||||
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||
|
||||
'@codemirror/lang-go@6.0.1':
|
||||
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
|
||||
|
||||
'@codemirror/lang-html@6.4.11':
|
||||
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
|
||||
|
||||
'@codemirror/lang-java@6.0.2':
|
||||
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
|
||||
|
||||
'@codemirror/lang-javascript@6.2.4':
|
||||
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
||||
|
||||
'@codemirror/lang-less@6.0.2':
|
||||
resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
|
||||
|
||||
'@codemirror/lang-markdown@6.5.0':
|
||||
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
|
||||
|
||||
'@codemirror/lang-php@6.0.2':
|
||||
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
|
||||
|
||||
'@codemirror/lang-python@6.2.1':
|
||||
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
|
||||
|
||||
'@codemirror/lang-rust@6.0.2':
|
||||
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
|
||||
|
||||
'@codemirror/lang-sass@6.0.2':
|
||||
resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
|
||||
|
||||
'@codemirror/lang-sql@6.10.0':
|
||||
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
|
||||
|
||||
'@codemirror/lang-vue@0.1.3':
|
||||
resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
|
||||
|
||||
'@codemirror/lang-wast@6.0.2':
|
||||
resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
|
||||
|
||||
'@codemirror/lang-xml@6.1.0':
|
||||
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
|
||||
|
||||
'@codemirror/lang-yaml@6.1.2':
|
||||
resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
|
||||
|
||||
'@codemirror/language@6.12.1':
|
||||
resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
|
||||
|
||||
'@codemirror/legacy-modes@6.5.2':
|
||||
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
|
||||
|
||||
'@codemirror/lint@6.9.3':
|
||||
resolution: {integrity: sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==}
|
||||
|
||||
'@codemirror/search@6.6.0':
|
||||
resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
|
||||
|
||||
'@codemirror/state@6.5.4':
|
||||
resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
|
||||
|
||||
'@codemirror/view@6.39.11':
|
||||
resolution: {integrity: sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -590,60 +405,6 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lezer/common@1.5.0':
|
||||
resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==}
|
||||
|
||||
'@lezer/cpp@1.1.5':
|
||||
resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==}
|
||||
|
||||
'@lezer/css@1.3.0':
|
||||
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
|
||||
|
||||
'@lezer/go@1.0.1':
|
||||
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
|
||||
|
||||
'@lezer/html@1.3.13':
|
||||
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
|
||||
|
||||
'@lezer/java@1.1.3':
|
||||
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
|
||||
|
||||
'@lezer/javascript@1.5.4':
|
||||
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
|
||||
|
||||
'@lezer/json@1.0.3':
|
||||
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
|
||||
|
||||
'@lezer/lr@1.4.8':
|
||||
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
|
||||
|
||||
'@lezer/markdown@1.6.3':
|
||||
resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
|
||||
|
||||
'@lezer/php@1.0.5':
|
||||
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
|
||||
|
||||
'@lezer/python@1.1.18':
|
||||
resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
|
||||
|
||||
'@lezer/rust@1.0.2':
|
||||
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
|
||||
|
||||
'@lezer/sass@1.1.0':
|
||||
resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
|
||||
|
||||
'@lezer/xml@1.0.6':
|
||||
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
|
||||
|
||||
'@lezer/yaml@1.0.3':
|
||||
resolution: {integrity: sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
@@ -1108,15 +869,6 @@ packages:
|
||||
resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vitest/coverage-v8@4.0.18':
|
||||
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.0.18
|
||||
vitest: 4.0.18
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.0.17':
|
||||
resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==}
|
||||
|
||||
@@ -1134,9 +886,6 @@ packages:
|
||||
'@vitest/pretty-format@4.0.17':
|
||||
resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==}
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
|
||||
|
||||
'@vitest/runner@4.0.17':
|
||||
resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==}
|
||||
|
||||
@@ -1149,9 +898,6 @@ packages:
|
||||
'@vitest/utils@4.0.17':
|
||||
resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==}
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
@@ -1195,9 +941,6 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@0.3.10:
|
||||
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
|
||||
|
||||
axobject-query@4.1.0:
|
||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1234,9 +977,6 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
codemirror@6.0.2:
|
||||
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -1251,9 +991,6 @@ packages:
|
||||
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1474,9 +1211,6 @@ packages:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -1522,18 +1256,6 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
@@ -1541,9 +1263,6 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
@@ -1675,13 +1394,6 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.5.1:
|
||||
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
marked@17.0.1:
|
||||
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -1888,9 +1600,6 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
style-mod@4.1.3:
|
||||
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2070,9 +1779,6 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2163,233 +1869,10 @@ snapshots:
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/parser@7.28.6':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.6
|
||||
|
||||
'@babel/runtime@7.28.6': {}
|
||||
|
||||
'@babel/types@7.28.6':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@codemirror/autocomplete@6.20.0':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
'@lezer/common': 1.5.0
|
||||
|
||||
'@codemirror/commands@6.8.1':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
'@lezer/common': 1.5.0
|
||||
|
||||
'@codemirror/lang-angular@0.1.4':
|
||||
dependencies:
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/lang-javascript': 6.2.4
|
||||
'@codemirror/language': 6.12.1
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@codemirror/lang-cpp@6.0.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@lezer/cpp': 1.1.5
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/css': 1.3.0
|
||||
|
||||
'@codemirror/lang-go@6.0.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/go': 1.0.1
|
||||
|
||||
'@codemirror/lang-html@6.4.11':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/lang-javascript': 6.2.4
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/css': 1.3.0
|
||||
'@lezer/html': 1.3.13
|
||||
|
||||
'@codemirror/lang-java@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@lezer/java': 1.1.3
|
||||
|
||||
'@codemirror/lang-javascript@6.2.4':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/lint': 6.9.3
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/javascript': 1.5.4
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@lezer/json': 1.0.3
|
||||
|
||||
'@codemirror/lang-less@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/language': 6.12.1
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@codemirror/lang-markdown@6.5.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/markdown': 1.6.3
|
||||
|
||||
'@codemirror/lang-php@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/php': 1.0.5
|
||||
|
||||
'@codemirror/lang-python@6.2.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/python': 1.1.18
|
||||
|
||||
'@codemirror/lang-rust@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@lezer/rust': 1.0.2
|
||||
|
||||
'@codemirror/lang-sass@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/sass': 1.1.0
|
||||
|
||||
'@codemirror/lang-sql@6.10.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@codemirror/lang-vue@0.1.3':
|
||||
dependencies:
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/lang-javascript': 6.2.4
|
||||
'@codemirror/language': 6.12.1
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@codemirror/lang-wast@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@codemirror/lang-xml@6.1.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/xml': 1.0.6
|
||||
|
||||
'@codemirror/lang-yaml@6.1.2':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
'@lezer/yaml': 1.0.3
|
||||
|
||||
'@codemirror/language@6.12.1':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
style-mod: 4.1.3
|
||||
|
||||
'@codemirror/legacy-modes@6.5.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
|
||||
'@codemirror/lint@6.9.3':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/search@6.6.0':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/state@6.5.4':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@codemirror/view@6.39.11':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
crelt: 1.0.6
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
@@ -2568,101 +2051,6 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lezer/common@1.5.0': {}
|
||||
|
||||
'@lezer/cpp@1.1.5':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/css@1.3.0':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/go@1.0.1':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
|
||||
'@lezer/html@1.3.13':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/java@1.1.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/javascript@1.5.4':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/json@1.0.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/lr@1.4.8':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
|
||||
'@lezer/markdown@1.6.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@lezer/php@1.0.5':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/python@1.1.18':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/rust@1.0.2':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/sass@1.1.0':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/xml@1.0.6':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/yaml@1.0.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.55.1':
|
||||
@@ -3081,20 +2469,6 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@vitest/coverage-v8@4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.18
|
||||
ast-v8-to-istanbul: 0.3.10
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.1
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)
|
||||
|
||||
'@vitest/expect@4.0.17':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -3116,10 +2490,6 @@ snapshots:
|
||||
dependencies:
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
dependencies:
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/runner@4.0.17':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.0.17
|
||||
@@ -3138,11 +2508,6 @@ snapshots:
|
||||
'@vitest/pretty-format': 4.0.17
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
@@ -3176,12 +2541,6 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@0.3.10:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 9.0.1
|
||||
|
||||
axobject-query@4.1.0: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
@@ -3214,16 +2573,6 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
codemirror@6.0.2:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/commands': 6.8.1
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/lint': 6.9.3
|
||||
'@codemirror/search': 6.6.0
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.11
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -3234,8 +2583,6 @@ snapshots:
|
||||
|
||||
cookie@0.6.0: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -3476,8 +2823,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@exodus/crypto'
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -3519,25 +2864,10 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
@@ -3656,16 +2986,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.5.1:
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
marked@17.0.1: {}
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
@@ -3852,8 +3172,6 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
style-mod@4.1.3: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -4013,8 +3331,6 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
@@ -592,7 +592,7 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1080,15 +1080,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -1096,7 +1087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared 0.3.1",
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1110,12 +1101,6 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -1617,13 +1602,10 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hikari-desktop"
|
||||
version = "1.1.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1734,22 +1716,6 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.19"
|
||||
@@ -2322,23 +2288,6 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2712,50 +2661,6 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -3512,12 +3417,10 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -3528,7 +3431,6 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
@@ -3664,15 +3566,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -3730,29 +3623,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -4876,16 +4746,6 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -5251,12 +5111,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "1.1.1"
|
||||
version = "0.3.0"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
@@ -31,9 +31,6 @@ 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 = [
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 466 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 878 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 11 KiB |
@@ -4,11 +4,11 @@ use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::provider_bridge::ProviderBridge;
|
||||
use crate::stats::UsageStats;
|
||||
use crate::wsl_bridge::WslBridge;
|
||||
|
||||
pub struct BridgeManager {
|
||||
bridges: HashMap<String, ProviderBridge>,
|
||||
bridges: HashMap<String, WslBridge>,
|
||||
app_handle: Option<AppHandle>,
|
||||
}
|
||||
|
||||
@@ -45,24 +45,12 @@ impl BridgeManager {
|
||||
.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()));
|
||||
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
|
||||
|
||||
// Start the Claude process
|
||||
bridge.start(app, options)?;
|
||||
|
||||
@@ -257,468 +257,3 @@ pub fn update_clipboard_language(
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,425 +393,3 @@ pub async fn get_file_size(file_path: String) -> Result<u64, String> {
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
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,
|
||||
|
||||
@@ -29,47 +25,10 @@ pub struct ClaudeStartOptions {
|
||||
|
||||
#[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>,
|
||||
|
||||
@@ -85,40 +44,6 @@ pub struct HikariConfig {
|
||||
#[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,
|
||||
|
||||
@@ -167,31 +92,16 @@ pub struct HikariConfig {
|
||||
|
||||
#[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,
|
||||
@@ -208,7 +118,6 @@ impl Default for HikariConfig {
|
||||
profile_name: None,
|
||||
profile_avatar_path: None,
|
||||
profile_bio: None,
|
||||
custom_theme_colors: CustomThemeColors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,18 +142,6 @@ 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 {
|
||||
@@ -253,27 +150,6 @@ pub enum Theme {
|
||||
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)]
|
||||
@@ -283,26 +159,11 @@ mod tests {
|
||||
#[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());
|
||||
@@ -317,28 +178,16 @@ mod tests {
|
||||
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()),
|
||||
@@ -355,7 +204,6 @@ mod tests {
|
||||
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();
|
||||
@@ -384,8 +232,5 @@ mod tests {
|
||||
serde_json::to_string(&high_contrast).unwrap(),
|
||||
"\"high-contrast\""
|
||||
);
|
||||
|
||||
let custom = Theme::Custom;
|
||||
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,593 +286,3 @@ pub fn git_discard(working_dir: String, file_path: String) -> Result<String, Str
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ mod commands;
|
||||
mod config;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod provider_bridge;
|
||||
mod providers;
|
||||
mod quick_actions;
|
||||
mod sessions;
|
||||
mod snippets;
|
||||
@@ -153,14 +151,6 @@ pub fn run() {
|
||||
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!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,773 +0,0 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
// 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\""));
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
@@ -171,18 +171,6 @@ pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> {
|
||||
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();
|
||||
@@ -200,174 +188,4 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,30 +145,6 @@ pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> {
|
||||
#[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() {
|
||||
@@ -188,187 +164,4 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,19 +205,6 @@ pub async fn reset_default_snippets(app: AppHandle) -> Result<(), String> {
|
||||
#[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() {
|
||||
@@ -236,204 +223,4 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,36 +369,6 @@ mod tests {
|
||||
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();
|
||||
@@ -411,28 +381,6 @@ mod tests {
|
||||
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();
|
||||
@@ -446,230 +394,4 @@ mod tests {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,290 +137,3 @@ 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_"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -704,7 +704,7 @@ fn process_json_line(
|
||||
subtype,
|
||||
result,
|
||||
permission_denials,
|
||||
usage,
|
||||
usage: _,
|
||||
..
|
||||
} => {
|
||||
let state = if subtype == "success" {
|
||||
@@ -713,21 +713,6 @@ fn process_json_line(
|
||||
CharacterState::Error
|
||||
};
|
||||
|
||||
// Track token usage from Result messages if available
|
||||
// This captures tokens from tool outputs and other operations
|
||||
if let Some(usage_info) = usage {
|
||||
// We need the model info to calculate cost properly
|
||||
// For now, use the last known model from stats
|
||||
let model = {
|
||||
let stats_guard = stats.read();
|
||||
stats_guard.model.clone().unwrap_or_else(|| "claude-opus-4-20250514".to_string())
|
||||
};
|
||||
|
||||
let mut stats_guard = stats.write();
|
||||
stats_guard.add_usage(usage_info.input_tokens, usage_info.output_tokens, &model);
|
||||
println!("Result message tokens - input: {}, output: {}", usage_info.input_tokens, usage_info.output_tokens);
|
||||
}
|
||||
|
||||
// Always emit updated stats on result message (less frequent)
|
||||
// This includes the latest session duration
|
||||
let newly_unlocked = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "1.1.1",
|
||||
"version": "0.3.0",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -249,9 +249,6 @@
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
opacity: 0;
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
configStore,
|
||||
type HikariConfig,
|
||||
type Theme,
|
||||
type ProviderType,
|
||||
type CustomThemeColors,
|
||||
applyFontSize,
|
||||
applyCustomThemeColors,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
DEFAULT_FONT_SIZE,
|
||||
@@ -15,22 +12,11 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
let config: HikariConfig = $state({
|
||||
provider_type: "claude_cli",
|
||||
model: null,
|
||||
api_key: null,
|
||||
custom_instructions: null,
|
||||
mcp_servers_json: null,
|
||||
auto_granted_tools: [],
|
||||
ollama_base_url: "http://localhost:11434",
|
||||
ollama_model: null,
|
||||
openai_api_key: null,
|
||||
openai_base_url: "https://api.openai.com/v1",
|
||||
openai_model: null,
|
||||
anthropic_api_key: null,
|
||||
anthropic_base_url: "https://api.anthropic.com",
|
||||
anthropic_model: null,
|
||||
gemini_api_key: null,
|
||||
gemini_model: null,
|
||||
theme: "dark",
|
||||
greeting_enabled: true,
|
||||
greeting_custom_prompt: null,
|
||||
@@ -47,20 +33,8 @@
|
||||
profile_name: null,
|
||||
profile_avatar_path: null,
|
||||
profile_bio: null,
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
});
|
||||
|
||||
let showCustomThemeEditor = $state(false);
|
||||
|
||||
let isOpen = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let saveError: string | null = $state(null);
|
||||
@@ -84,53 +58,12 @@
|
||||
grantedTools = Array.from(tools);
|
||||
});
|
||||
|
||||
const availableProviders: { value: ProviderType; label: string; description: string }[] = [
|
||||
{ value: "claude_cli", label: "Claude CLI", description: "Use Claude Code CLI for AI assistance" },
|
||||
{ value: "ollama", label: "Ollama (Local)", description: "Use locally running Ollama models" },
|
||||
{ value: "open_ai", label: "OpenAI API", description: "Direct OpenAI API access (GPT-4o, etc.)" },
|
||||
{ value: "anthropic", label: "Anthropic API", description: "Direct Anthropic API access (Claude models)" },
|
||||
{ value: "gemini", label: "Google Gemini", description: "Direct Google Gemini API access" },
|
||||
];
|
||||
|
||||
const availableModels = [
|
||||
{ value: "", label: "Default (from ~/.claude)" },
|
||||
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
||||
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
||||
];
|
||||
|
||||
const ollamaModels = [
|
||||
{ value: "", label: "Default (llama3.2)" },
|
||||
{ value: "llama3.2", label: "Llama 3.2" },
|
||||
{ value: "llama3.2:1b", label: "Llama 3.2 1B" },
|
||||
{ value: "qwen2.5-coder", label: "Qwen 2.5 Coder" },
|
||||
{ value: "deepseek-coder-v2", label: "DeepSeek Coder V2" },
|
||||
{ value: "mistral", label: "Mistral 7B" },
|
||||
{ value: "gemma2", label: "Gemma 2" },
|
||||
];
|
||||
|
||||
const openaiModels = [
|
||||
{ value: "", label: "Default (gpt-4o)" },
|
||||
{ value: "gpt-4o", label: "GPT-4o" },
|
||||
{ value: "gpt-4o-mini", label: "GPT-4o Mini" },
|
||||
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
|
||||
{ value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
|
||||
];
|
||||
|
||||
const anthropicModels = [
|
||||
{ value: "", label: "Default (Claude Sonnet 4.5)" },
|
||||
{ value: "claude-sonnet-4-5-20250514", label: "Claude Sonnet 4.5" },
|
||||
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
||||
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet" },
|
||||
{ value: "claude-3-5-haiku-20241022", label: "Claude 3.5 Haiku" },
|
||||
];
|
||||
|
||||
const geminiModels = [
|
||||
{ value: "", label: "Default (gemini-2.0-flash)" },
|
||||
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||
{ value: "gemini-1.5-pro", label: "Gemini 1.5 Pro" },
|
||||
{ value: "gemini-1.5-flash", label: "Gemini 1.5 Flash" },
|
||||
];
|
||||
|
||||
const commonTools = [
|
||||
"Read",
|
||||
"Write",
|
||||
@@ -158,33 +91,9 @@
|
||||
|
||||
async function handleThemeChange(theme: Theme) {
|
||||
config.theme = theme;
|
||||
showCustomThemeEditor = theme === "custom";
|
||||
await configStore.setTheme(theme, config.custom_theme_colors);
|
||||
await configStore.setTheme(theme);
|
||||
}
|
||||
|
||||
function handleCustomColorChange(key: keyof CustomThemeColors, value: string) {
|
||||
config.custom_theme_colors = {
|
||||
...config.custom_theme_colors,
|
||||
[key]: value || null,
|
||||
};
|
||||
// Live preview
|
||||
if (config.theme === "custom") {
|
||||
applyCustomThemeColors(config.custom_theme_colors);
|
||||
}
|
||||
}
|
||||
|
||||
// Default dark theme colors for color picker defaults
|
||||
const defaultDarkColors: Required<Record<keyof CustomThemeColors, string>> = {
|
||||
bg_primary: "#1a1a2e",
|
||||
bg_secondary: "#16213e",
|
||||
bg_terminal: "#0f0f1a",
|
||||
accent_primary: "#e94560",
|
||||
accent_secondary: "#ff6b9d",
|
||||
text_primary: "#ffffff",
|
||||
text_secondary: "#a0a0a0",
|
||||
border_color: "#2a2a4a",
|
||||
};
|
||||
|
||||
function toggleTool(tool: string) {
|
||||
if (config.auto_granted_tools.includes(tool)) {
|
||||
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
|
||||
@@ -260,235 +169,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Provider Selection Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
AI Provider
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each availableProviders as provider (provider.value)}
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors {config.provider_type === provider.value
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]/10'
|
||||
: 'border-[var(--border-color)] bg-[var(--bg-primary)] hover:border-[var(--accent-primary)]/50'}">
|
||||
<input
|
||||
type="radio"
|
||||
name="provider"
|
||||
value={provider.value}
|
||||
checked={config.provider_type === provider.value}
|
||||
onchange={() => config.provider_type = provider.value}
|
||||
class="mt-1 w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] focus:ring-[var(--accent-primary)]"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-[var(--text-primary)]">{provider.label}</div>
|
||||
<div class="text-xs text-[var(--text-tertiary)]">{provider.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Ollama-specific settings -->
|
||||
{#if config.provider_type === "ollama"}
|
||||
<div class="mt-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
|
||||
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">Ollama Settings</h4>
|
||||
|
||||
<!-- Ollama Base URL -->
|
||||
<div class="mb-3">
|
||||
<label for="ollama-url" class="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Base URL
|
||||
</label>
|
||||
<input
|
||||
id="ollama-url"
|
||||
type="text"
|
||||
bind:value={config.ollama_base_url}
|
||||
placeholder="http://localhost:11434"
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Ollama Model Selection -->
|
||||
<div class="mb-3">
|
||||
<label for="ollama-model" class="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
id="ollama-model"
|
||||
bind:value={config.ollama_model}
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
{#each ollamaModels as model (model.value)}
|
||||
<option value={model.value}>{model.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Make sure the model is downloaded via <code class="text-[var(--accent-secondary)]">ollama pull</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400">
|
||||
<strong>Note:</strong> Ollama doesn't support tools, MCP servers, or thinking blocks.
|
||||
For full Claude Code features, use the Claude CLI provider.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- OpenAI-specific settings -->
|
||||
{#if config.provider_type === "open_ai"}
|
||||
<div class="mt-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
|
||||
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">OpenAI Settings</h4>
|
||||
|
||||
<!-- OpenAI API Key -->
|
||||
<div class="mb-3">
|
||||
<label for="openai-api-key" class="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
id="openai-api-key"
|
||||
type="password"
|
||||
bind:value={config.openai_api_key}
|
||||
placeholder="sk-..."
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Base URL -->
|
||||
<div class="mb-3">
|
||||
<label for="openai-url" class="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Base URL <span class="text-[var(--text-tertiary)]">(for OpenAI-compatible APIs)</span>
|
||||
</label>
|
||||
<input
|
||||
id="openai-url"
|
||||
type="text"
|
||||
bind:value={config.openai_base_url}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Model Selection -->
|
||||
<div class="mb-3">
|
||||
<label for="openai-model" class="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
id="openai-model"
|
||||
bind:value={config.openai_model}
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
{#each openaiModels as model (model.value)}
|
||||
<option value={model.value}>{model.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="p-2 bg-blue-500/10 border border-blue-500/30 rounded text-xs text-blue-400">
|
||||
<strong>Tip:</strong> You can use this with any OpenAI-compatible API (Groq, Together AI, etc.)
|
||||
by changing the Base URL.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Anthropic-specific settings -->
|
||||
{#if config.provider_type === "anthropic"}
|
||||
<div class="mt-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
|
||||
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">Anthropic Settings</h4>
|
||||
|
||||
<!-- Anthropic API Key -->
|
||||
<div class="mb-3">
|
||||
<label for="anthropic-api-key" class="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
id="anthropic-api-key"
|
||||
type="password"
|
||||
bind:value={config.anthropic_api_key}
|
||||
placeholder="sk-ant-..."
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Anthropic Base URL -->
|
||||
<div class="mb-3">
|
||||
<label for="anthropic-url" class="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Base URL <span class="text-[var(--text-tertiary)]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="anthropic-url"
|
||||
type="text"
|
||||
bind:value={config.anthropic_base_url}
|
||||
placeholder="https://api.anthropic.com"
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Anthropic Model Selection -->
|
||||
<div class="mb-3">
|
||||
<label for="anthropic-model" class="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
id="anthropic-model"
|
||||
bind:value={config.anthropic_model}
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
{#each anthropicModels as model (model.value)}
|
||||
<option value={model.value}>{model.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="p-2 bg-purple-500/10 border border-purple-500/30 rounded text-xs text-purple-400">
|
||||
<strong>Note:</strong> This uses the Anthropic API directly without Claude Code CLI features
|
||||
like tools, MCP, or thinking blocks.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Gemini-specific settings -->
|
||||
{#if config.provider_type === "gemini"}
|
||||
<div class="mt-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
|
||||
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">Gemini Settings</h4>
|
||||
|
||||
<!-- Gemini API Key -->
|
||||
<div class="mb-3">
|
||||
<label for="gemini-api-key" class="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
id="gemini-api-key"
|
||||
type="password"
|
||||
bind:value={config.gemini_api_key}
|
||||
placeholder="AIza..."
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Get your API key from <a href="https://aistudio.google.com/apikey" target="_blank" class="text-[var(--accent-secondary)] hover:underline">Google AI Studio</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Gemini Model Selection -->
|
||||
<div class="mb-3">
|
||||
<label for="gemini-model" class="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
id="gemini-model"
|
||||
bind:value={config.gemini_model}
|
||||
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
{#each geminiModels as model (model.value)}
|
||||
<option value={model.value}>{model.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="p-2 bg-green-500/10 border border-green-500/30 rounded text-xs text-green-400">
|
||||
<strong>Note:</strong> Gemini has a generous free tier! Great for experimenting.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Agent Settings Section -->
|
||||
<section class="mb-6">
|
||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||
@@ -740,12 +420,11 @@
|
||||
|
||||
<!-- Theme Selection -->
|
||||
<div class="mb-4">
|
||||
<span class="block text-sm text-[var(--text-secondary)] mb-2">Theme</span>
|
||||
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
|
||||
<label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => handleThemeChange("dark")}
|
||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||
'dark'
|
||||
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme === 'dark'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
@@ -753,8 +432,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleThemeChange("light")}
|
||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||
'light'
|
||||
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme === 'light'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
>
|
||||
@@ -762,178 +440,17 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleThemeChange("high-contrast")}
|
||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||
'high-contrast'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
title="High contrast mode for improved accessibility"
|
||||
>
|
||||
Contrast
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleThemeChange("custom")}
|
||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||
'custom'
|
||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||
title="Create your own custom theme"
|
||||
>
|
||||
Custom
|
||||
High Contrast
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Theme Editor -->
|
||||
{#if config.theme === "custom" || showCustomThemeEditor}
|
||||
<div class="mb-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
|
||||
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">Custom Theme Colors</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-primary"
|
||||
>Background</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-bg-primary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
|
||||
oninput={(e) => handleCustomColorChange("bg_primary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-secondary"
|
||||
>Secondary BG</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-bg-secondary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
|
||||
oninput={(e) => handleCustomColorChange("bg_secondary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-terminal"
|
||||
>Terminal BG</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-bg-terminal"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
|
||||
oninput={(e) => handleCustomColorChange("bg_terminal", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-border">Border</label>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-border"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.border_color || defaultDarkColors.border_color}
|
||||
oninput={(e) => handleCustomColorChange("border_color", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.border_color || defaultDarkColors.border_color}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-primary"
|
||||
>Accent Primary</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-accent-primary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.accent_primary ||
|
||||
defaultDarkColors.accent_primary}
|
||||
oninput={(e) => handleCustomColorChange("accent_primary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.accent_primary || defaultDarkColors.accent_primary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-secondary"
|
||||
>Accent Secondary</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-accent-secondary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.accent_secondary ||
|
||||
defaultDarkColors.accent_secondary}
|
||||
oninput={(e) =>
|
||||
handleCustomColorChange("accent_secondary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.accent_secondary ||
|
||||
defaultDarkColors.accent_secondary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-text-primary"
|
||||
>Text Primary</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-text-primary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
|
||||
oninput={(e) => handleCustomColorChange("text_primary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<label class="text-xs text-[var(--text-secondary)]" for="color-text-secondary"
|
||||
>Text Secondary</label
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="color-text-secondary"
|
||||
type="color"
|
||||
value={config.custom_theme_colors.text_secondary ||
|
||||
defaultDarkColors.text_secondary}
|
||||
oninput={(e) => handleCustomColorChange("text_secondary", e.currentTarget.value)}
|
||||
class="color-picker"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{config.custom_theme_colors.text_secondary || defaultDarkColors.text_secondary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-3">
|
||||
Changes preview live. Click Save Settings to persist.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Font Size -->
|
||||
<div class="mb-4">
|
||||
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||
@@ -1147,39 +664,4 @@
|
||||
background: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Color picker styling */
|
||||
.color-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.color-picker::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.color-picker::-webkit-color-swatch {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.color-picker::-moz-color-swatch {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.color-picker:hover {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -279,15 +279,8 @@
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="git-panel-overlay" on:click={onClose} role="presentation">
|
||||
<div
|
||||
class="git-panel"
|
||||
on:click|stopPropagation
|
||||
role="dialog"
|
||||
aria-label="Git Panel"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="git-panel" on:click|stopPropagation role="dialog" aria-label="Git Panel">
|
||||
<div class="git-panel-header">
|
||||
<h2>🔀 Git</h2>
|
||||
<button class="close-btn" on:click={onClose} title="Close (Esc)">âś•</button>
|
||||
@@ -587,15 +580,8 @@
|
||||
</div>
|
||||
|
||||
{#if showDiff}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="diff-overlay" on:click={() => (showDiff = false)} role="presentation">
|
||||
<div
|
||||
class="diff-modal"
|
||||
on:click|stopPropagation
|
||||
role="dialog"
|
||||
aria-label="Diff View"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="diff-modal" on:click|stopPropagation role="dialog" aria-label="Diff View">
|
||||
<div class="diff-header">
|
||||
<h3>đź“„ {diffFile}</h3>
|
||||
<button on:click={() => (showDiff = false)} title="Close">âś•</button>
|
||||
@@ -952,6 +938,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-branch-input button:first-of-type {
|
||||
/* Styling handled by btn-trans-gradient class */
|
||||
}
|
||||
|
||||
.new-branch-input button:last-of-type {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
||||
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
|
||||
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
||||
import type { Attachment } from "$lib/types/messages";
|
||||
|
||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||
@@ -50,23 +49,6 @@
|
||||
let showClipboardHistory = $state(false);
|
||||
let streamerModeActive = $state(false);
|
||||
|
||||
// Context menu state
|
||||
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
||||
let contextMenuShow = $state(false);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
|
||||
function handleContextMenu(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
contextMenuShow = true;
|
||||
contextMenuX = event.clientX;
|
||||
contextMenuY = event.clientY;
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenuShow = false;
|
||||
}
|
||||
|
||||
isStreamerMode.subscribe((value) => {
|
||||
streamerModeActive = value;
|
||||
});
|
||||
@@ -894,12 +876,10 @@ User: ${formattedMessage}`;
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeyDown}
|
||||
oninput={handleInputChange}
|
||||
onpaste={handlePaste}
|
||||
oncontextmenu={handleContextMenu}
|
||||
placeholder={isConnected
|
||||
? "Ask Hikari anything... (type / for commands)"
|
||||
: "Connect to Claude first..."}
|
||||
@@ -978,15 +958,6 @@ User: ${formattedMessage}`;
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if contextMenuShow && textareaElement}
|
||||
<TextInputContextMenu
|
||||
x={contextMenuX}
|
||||
y={contextMenuY}
|
||||
inputElement={textareaElement}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.input-bar {
|
||||
display: flex;
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
{ keys: ["Escape"], description: "Close modals and panels" },
|
||||
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
|
||||
{ keys: ["Ctrl", ","], description: "Open settings" },
|
||||
{ keys: ["Ctrl", "E"], description: "Toggle file editor" },
|
||||
{ keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" },
|
||||
{ keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" },
|
||||
],
|
||||
@@ -27,17 +26,6 @@
|
||||
{ keys: ["↓"], description: "Next input from history" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "File Editor",
|
||||
items: [
|
||||
{ keys: ["Ctrl", "E"], description: "Toggle editor view" },
|
||||
{ keys: ["Ctrl", "B"], description: "Toggle file browser" },
|
||||
{ keys: ["Ctrl", "S"], description: "Save current file" },
|
||||
{ keys: ["Ctrl", "W"], description: "Close current tab" },
|
||||
{ keys: ["Ctrl", "N"], description: "New file" },
|
||||
{ keys: ["Right-click"], description: "Context menu (New/Delete)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Slash Commands",
|
||||
items: [
|
||||
|
||||
@@ -39,12 +39,11 @@
|
||||
|
||||
let isGeneratingImage = false;
|
||||
|
||||
$: unlockedCount = Object.values($achievementsStore.achievements).filter(
|
||||
(a) => a.unlocked
|
||||
).length;
|
||||
$: unlockedCount = Object.values($achievementsStore.achievements).filter((a) => a.unlocked).length;
|
||||
$: totalAchievements = Object.values($achievementsStore.achievements).length;
|
||||
$: achievementPercentage =
|
||||
totalAchievements > 0 ? Math.round((unlockedCount / totalAchievements) * 100) : 0;
|
||||
$: achievementPercentage = totalAchievements > 0
|
||||
? Math.round((unlockedCount / totalAchievements) * 100)
|
||||
: 0;
|
||||
|
||||
async function selectAvatar() {
|
||||
try {
|
||||
@@ -83,15 +82,11 @@
|
||||
async function loadAvatarAsDataUrl(path: string): Promise<string | null> {
|
||||
try {
|
||||
const data = await readFile(path);
|
||||
const extension = path.split(".").pop()?.toLowerCase() || "png";
|
||||
const mimeType =
|
||||
extension === "jpg" || extension === "jpeg"
|
||||
? "image/jpeg"
|
||||
: extension === "gif"
|
||||
? "image/gif"
|
||||
: extension === "webp"
|
||||
? "image/webp"
|
||||
: "image/png";
|
||||
const extension = path.split('.').pop()?.toLowerCase() || 'png';
|
||||
const mimeType = extension === 'jpg' || extension === 'jpeg' ? 'image/jpeg'
|
||||
: extension === 'gif' ? 'image/gif'
|
||||
: extension === 'webp' ? 'image/webp'
|
||||
: 'image/png';
|
||||
// Convert Uint8Array to base64 in chunks to avoid stack overflow
|
||||
const blob = new Blob([data], { type: mimeType });
|
||||
return new Promise((resolve) => {
|
||||
@@ -128,7 +123,7 @@
|
||||
const stripeColors = ["#5bcefa", "#f5a9b8", "#ffffff", "#f5a9b8", "#5bcefa"];
|
||||
stripeColors.forEach((color, i) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, i * (stripeHeight / 5) * 2, width, (stripeHeight / 5) * 2);
|
||||
ctx.fillRect(0, i * (stripeHeight / 5) * 2, width, stripeHeight / 5 * 2);
|
||||
});
|
||||
|
||||
// Border
|
||||
@@ -159,13 +154,7 @@
|
||||
img.onerror = () => reject();
|
||||
img.src = dataUrl;
|
||||
});
|
||||
ctx.drawImage(
|
||||
img,
|
||||
avatarX - avatarRadius,
|
||||
avatarY - avatarRadius,
|
||||
avatarRadius * 2,
|
||||
avatarRadius * 2
|
||||
);
|
||||
ctx.drawImage(img, avatarX - avatarRadius, avatarY - avatarRadius, avatarRadius * 2, avatarRadius * 2);
|
||||
avatarLoaded = true;
|
||||
}
|
||||
} catch {
|
||||
@@ -174,22 +163,12 @@
|
||||
}
|
||||
|
||||
if (!avatarLoaded) {
|
||||
const avatarGradient = ctx.createLinearGradient(
|
||||
avatarX - avatarRadius,
|
||||
avatarY - avatarRadius,
|
||||
avatarX + avatarRadius,
|
||||
avatarY + avatarRadius
|
||||
);
|
||||
const avatarGradient = ctx.createLinearGradient(avatarX - avatarRadius, avatarY - avatarRadius, avatarX + avatarRadius, avatarY + avatarRadius);
|
||||
avatarGradient.addColorStop(0, "#5bcefa");
|
||||
avatarGradient.addColorStop(0.5, "#f5a9b8");
|
||||
avatarGradient.addColorStop(1, "#5bcefa");
|
||||
ctx.fillStyle = avatarGradient;
|
||||
ctx.fillRect(
|
||||
avatarX - avatarRadius,
|
||||
avatarY - avatarRadius,
|
||||
avatarRadius * 2,
|
||||
avatarRadius * 2
|
||||
);
|
||||
ctx.fillRect(avatarX - avatarRadius, avatarY - avatarRadius, avatarRadius * 2, avatarRadius * 2);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
@@ -281,23 +260,13 @@
|
||||
progressGradient.addColorStop(1, "#5bcefa");
|
||||
ctx.fillStyle = progressGradient;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
progressX,
|
||||
progressY,
|
||||
progressWidth * (achievementPercentage / 100),
|
||||
progressHeight,
|
||||
22
|
||||
);
|
||||
ctx.roundRect(progressX, progressY, progressWidth * (achievementPercentage / 100), progressHeight, 22);
|
||||
ctx.fill();
|
||||
|
||||
// Progress text
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 36px system-ui, -apple-system, sans-serif";
|
||||
ctx.fillText(
|
||||
`${unlockedCount} / ${totalAchievements} (${achievementPercentage}%)`,
|
||||
progressX + progressWidth + 40,
|
||||
progressY + 34
|
||||
);
|
||||
ctx.fillText(`${unlockedCount} / ${totalAchievements} (${achievementPercentage}%)`, progressX + progressWidth + 40, progressY + 34);
|
||||
|
||||
// Hikari branding
|
||||
ctx.fillStyle = "#f5a9b8";
|
||||
@@ -343,14 +312,7 @@
|
||||
<div class="profile-header">
|
||||
<h2>Profile</h2>
|
||||
<button class="close-btn" on:click={onClose} aria-label="Close profile">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -359,13 +321,7 @@
|
||||
<div class="profile-content">
|
||||
<!-- Avatar Section -->
|
||||
<div class="avatar-section">
|
||||
<div
|
||||
class="avatar-container"
|
||||
on:click={selectAvatar}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:keydown={(e) => e.key === "Enter" && selectAvatar()}
|
||||
>
|
||||
<div class="avatar-container" on:click={selectAvatar} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && selectAvatar()}>
|
||||
{#if avatarDataUrl}
|
||||
<img src={avatarDataUrl} alt="Profile avatar" class="avatar-image" />
|
||||
<div class="avatar-overlay">
|
||||
@@ -373,14 +329,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="avatar-placeholder">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
@@ -401,26 +350,13 @@
|
||||
bind:value={nameInput}
|
||||
placeholder="Enter your name"
|
||||
class="name-input"
|
||||
on:keydown={(e) => e.key === "Enter" && saveName()}
|
||||
on:keydown={(e) => e.key === 'Enter' && saveName()}
|
||||
on:blur={saveName}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="name-display"
|
||||
on:click={() => {
|
||||
editingName = true;
|
||||
nameInput = config.profile_name || "";
|
||||
}}
|
||||
>
|
||||
<button class="name-display" on:click={() => { editingName = true; nameInput = config.profile_name || ""; }}>
|
||||
<span class="name-text">{config.profile_name || "Click to add name"}</span>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
@@ -441,13 +377,7 @@
|
||||
></textarea>
|
||||
<button class="save-bio-btn btn-trans-gradient" on:click={saveBio}>Save</button>
|
||||
{:else}
|
||||
<button
|
||||
class="bio-display"
|
||||
on:click={() => {
|
||||
editingBio = true;
|
||||
bioInput = config.profile_bio || "";
|
||||
}}
|
||||
>
|
||||
<button class="bio-display" on:click={() => { editingBio = true; bioInput = config.profile_bio || ""; }}>
|
||||
<span class="bio-text">{config.profile_bio || "Click to add a bio..."}</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -491,31 +421,18 @@
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {achievementPercentage}%"></div>
|
||||
</div>
|
||||
<span class="progress-text"
|
||||
>{unlockedCount} / {totalAchievements} ({achievementPercentage}%)</span
|
||||
>
|
||||
<span class="progress-text">{unlockedCount} / {totalAchievements} ({achievementPercentage}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Section -->
|
||||
<div class="share-section">
|
||||
<button
|
||||
class="share-btn btn-trans-gradient"
|
||||
on:click={shareProfile}
|
||||
disabled={isGeneratingImage}
|
||||
>
|
||||
<button class="share-btn btn-trans-gradient" on:click={shareProfile} disabled={isGeneratingImage}>
|
||||
{#if isGeneratingImage}
|
||||
<span class="spinner"></span>
|
||||
Generating...
|
||||
{:else}
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
@@ -844,14 +761,7 @@
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--trans-blue),
|
||||
var(--trans-pink),
|
||||
var(--trans-white),
|
||||
var(--trans-pink),
|
||||
var(--trans-blue)
|
||||
);
|
||||
background: linear-gradient(90deg, var(--trans-blue), var(--trans-pink), var(--trans-white), var(--trans-pink), var(--trans-blue));
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 3s linear infinite;
|
||||
border-radius: 6px;
|
||||
@@ -859,12 +769,8 @@
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
@@ -922,8 +828,6 @@
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -449,7 +449,6 @@
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -469,7 +469,6 @@
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -460,7 +460,6 @@
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,14 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stat-highlight {
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
import { onMount } from "svelte";
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
@@ -43,22 +42,11 @@
|
||||
let showProfile = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
provider_type: "claude_cli",
|
||||
model: null,
|
||||
api_key: null,
|
||||
custom_instructions: null,
|
||||
mcp_servers_json: null,
|
||||
auto_granted_tools: [],
|
||||
ollama_base_url: "http://localhost:11434",
|
||||
ollama_model: null,
|
||||
openai_api_key: null,
|
||||
openai_base_url: "https://api.openai.com/v1",
|
||||
openai_model: null,
|
||||
anthropic_api_key: null,
|
||||
anthropic_base_url: "https://api.anthropic.com",
|
||||
anthropic_model: null,
|
||||
gemini_api_key: null,
|
||||
gemini_model: null,
|
||||
theme: "dark",
|
||||
greeting_enabled: true,
|
||||
greeting_custom_prompt: null,
|
||||
@@ -75,16 +63,6 @@
|
||||
profile_name: null,
|
||||
profile_avatar_path: null,
|
||||
profile_bio: null,
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
@@ -92,15 +70,6 @@
|
||||
streamerModeActive = value;
|
||||
});
|
||||
|
||||
let editorVisible = $state(false);
|
||||
editorStore.isEditorVisible.subscribe((value) => {
|
||||
editorVisible = value;
|
||||
});
|
||||
|
||||
function toggleEditor() {
|
||||
editorStore.toggleEditor();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
appVersion = await getVersion();
|
||||
});
|
||||
@@ -156,23 +125,12 @@
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
provider_type: currentConfig.provider_type || "claude_cli",
|
||||
working_dir: targetDir,
|
||||
model: currentConfig.model || null,
|
||||
api_key: currentConfig.api_key || null,
|
||||
custom_instructions: currentConfig.custom_instructions || null,
|
||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
ollama_base_url: currentConfig.ollama_base_url || "http://localhost:11434",
|
||||
ollama_model: currentConfig.ollama_model || null,
|
||||
openai_api_key: currentConfig.openai_api_key || null,
|
||||
openai_base_url: currentConfig.openai_base_url || "https://api.openai.com/v1",
|
||||
openai_model: currentConfig.openai_model || null,
|
||||
anthropic_api_key: currentConfig.anthropic_api_key || null,
|
||||
anthropic_base_url: currentConfig.anthropic_base_url || "https://api.anthropic.com",
|
||||
anthropic_model: currentConfig.anthropic_model || null,
|
||||
gemini_api_key: currentConfig.gemini_api_key || null,
|
||||
gemini_model: currentConfig.gemini_model || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -339,25 +297,6 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleEditor}
|
||||
disabled={connectionStatus !== "connected"}
|
||||
class="p-1 text-gray-500 icon-trans-hover {editorVisible
|
||||
? 'text-[var(--trans-pink)]'
|
||||
: ''} disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={connectionStatus === "connected"
|
||||
? "File Editor (Ctrl+E)"
|
||||
: "Connect to enable file editor"}
|
||||
>
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showStats = !showStats)}
|
||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||
|
||||
@@ -155,30 +155,6 @@
|
||||
terminalElement.removeEventListener("copy", handleCopy);
|
||||
}
|
||||
});
|
||||
|
||||
// Copy message content to clipboard
|
||||
async function copyMessage(content: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
// Optionally capture to clipboard history
|
||||
await clipboardStore.captureClipboard(content, null, "Message from chat");
|
||||
|
||||
// Visual feedback could be added here if needed
|
||||
} catch (error) {
|
||||
console.error("Failed to copy message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// State for showing "Copied!" feedback
|
||||
let copiedMessageId: string | null = null;
|
||||
|
||||
async function handleCopyMessage(messageId: string, content: string) {
|
||||
await copyMessage(content);
|
||||
copiedMessageId = messageId;
|
||||
setTimeout(() => {
|
||||
copiedMessageId = null;
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -209,7 +185,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#each lines as line (line.id)}
|
||||
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
|
||||
<div class="terminal-line mb-2 {getLineClass(line.type)}">
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
@@ -217,33 +193,11 @@
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
{#if line.type === "assistant" || line.type === "user"}
|
||||
<div class="message-content-wrapper">
|
||||
<Markdown
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
<button
|
||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
||||
title="Copy message"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{#if line.type === "assistant"}
|
||||
<Markdown
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
@@ -313,45 +267,4 @@
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* Message content wrapper for positioning */
|
||||
.message-content-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Copy button styling */
|
||||
.copy-message-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25em 0.5em;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.copy-message-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-hover, var(--border-color));
|
||||
}
|
||||
|
||||
.copy-message-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Ensure relative positioning for parent */
|
||||
.terminal-line {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
inputElement: HTMLTextAreaElement | HTMLInputElement;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { x, y, inputElement, onClose }: Props = $props();
|
||||
|
||||
// Menu element reference for measuring
|
||||
let menuElement: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Adjusted position to keep menu within viewport
|
||||
let adjustedX = $derived.by(() => {
|
||||
if (!menuElement) return x;
|
||||
const menuWidth = menuElement.offsetWidth || 180;
|
||||
const maxX = window.innerWidth - menuWidth - 8;
|
||||
return Math.min(x, maxX);
|
||||
});
|
||||
|
||||
let adjustedY = $derived.by(() => {
|
||||
if (!menuElement) return y;
|
||||
const menuHeight = menuElement.offsetHeight || 250;
|
||||
const maxY = window.innerHeight - menuHeight - 8;
|
||||
return Math.min(y, maxY);
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function execCommand(command: "cut" | "copy" | "paste" | "selectAll" | "undo" | "redo") {
|
||||
inputElement.focus();
|
||||
|
||||
switch (command) {
|
||||
case "cut":
|
||||
document.execCommand("cut");
|
||||
break;
|
||||
case "copy":
|
||||
document.execCommand("copy");
|
||||
break;
|
||||
case "paste":
|
||||
document.execCommand("paste");
|
||||
break;
|
||||
case "selectAll":
|
||||
inputElement.select();
|
||||
break;
|
||||
case "undo":
|
||||
document.execCommand("undo");
|
||||
break;
|
||||
case "redo":
|
||||
document.execCommand("redo");
|
||||
break;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Check if there's a selection
|
||||
let hasSelection = $derived(inputElement.selectionStart !== inputElement.selectionEnd);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="menu-overlay"
|
||||
onclick={onClose}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="menu-content"
|
||||
style="left: {adjustedX}px; top: {adjustedY}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button class="menu-item" onclick={() => execCommand("undo")}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 10h10a5 5 0 0 1 5 5v2M3 10l4-4M3 10l4 4"
|
||||
/>
|
||||
</svg>
|
||||
Undo
|
||||
<span class="shortcut">Ctrl+Z</span>
|
||||
</button>
|
||||
|
||||
<button class="menu-item" onclick={() => execCommand("redo")}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 10H11a5 5 0 0 0-5 5v2M21 10l-4-4M21 10l-4 4"
|
||||
/>
|
||||
</svg>
|
||||
Redo
|
||||
<span class="shortcut">Ctrl+Y</span>
|
||||
</button>
|
||||
|
||||
<div class="menu-divider"></div>
|
||||
|
||||
<button class="menu-item" onclick={() => execCommand("cut")} disabled={!hasSelection}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.121 14.121L19 19m-7-7l7-7m-7 7l-2.879 2.879M12 12L9.121 9.121m0 5.758a3 3 0 1 0-4.243-4.243 3 3 0 0 0 4.243 4.243zm0-5.758a3 3 0 1 0-4.243-4.243 3 3 0 0 0 4.243 4.243z"
|
||||
/>
|
||||
</svg>
|
||||
Cut
|
||||
<span class="shortcut">Ctrl+X</span>
|
||||
</button>
|
||||
|
||||
<button class="menu-item" onclick={() => execCommand("copy")} disabled={!hasSelection}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2m-6 12h8a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Copy
|
||||
<span class="shortcut">Ctrl+C</span>
|
||||
</button>
|
||||
|
||||
<button class="menu-item" onclick={() => execCommand("paste")}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2"
|
||||
/>
|
||||
</svg>
|
||||
Paste
|
||||
<span class="shortcut">Ctrl+V</span>
|
||||
</button>
|
||||
|
||||
<div class="menu-divider"></div>
|
||||
|
||||
<button class="menu-item" onclick={() => execCommand("selectAll")}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9h6v6H9z" />
|
||||
</svg>
|
||||
Select All
|
||||
<span class="shortcut">Ctrl+A</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
min-width: 180px;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 0.25rem 0;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover:not(:disabled) {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.menu-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
margin: 0.25rem 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -17,7 +17,7 @@
|
||||
hasQuestionPending.subscribe((pending) => {
|
||||
isVisible = pending;
|
||||
if (!pending) {
|
||||
selectedOptions.clear();
|
||||
selectedOptions = new SvelteSet();
|
||||
customAnswer = "";
|
||||
showCustomInput = false;
|
||||
}
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { EditorState, Compartment } from "@codemirror/state";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import { rust } from "@codemirror/lang-rust";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { xml } from "@codemirror/lang-xml";
|
||||
import { sql } from "@codemirror/lang-sql";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { cpp } from "@codemirror/lang-cpp";
|
||||
import { php } from "@codemirror/lang-php";
|
||||
import { go } from "@codemirror/lang-go";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
import { sass } from "@codemirror/lang-sass";
|
||||
import { less } from "@codemirror/lang-less";
|
||||
import { vue } from "@codemirror/lang-vue";
|
||||
import { angular } from "@codemirror/lang-angular";
|
||||
import { wast } from "@codemirror/lang-wast";
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
|
||||
import { swift } from "@codemirror/legacy-modes/mode/swift";
|
||||
import { lua } from "@codemirror/legacy-modes/mode/lua";
|
||||
import { r } from "@codemirror/legacy-modes/mode/r";
|
||||
import { toml } from "@codemirror/legacy-modes/mode/toml";
|
||||
import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile";
|
||||
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import EditorContextMenu from "./EditorContextMenu.svelte";
|
||||
import type { EditorTab } from "$lib/types/editor";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
|
||||
export let tab: EditorTab;
|
||||
|
||||
let editorContainer: HTMLDivElement;
|
||||
let view: EditorView | null = null;
|
||||
let themeCompartment = new Compartment();
|
||||
|
||||
// Context menu state
|
||||
let contextMenuShow = false;
|
||||
let contextMenuX = 0;
|
||||
let contextMenuY = 0;
|
||||
|
||||
function handleContextMenu(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
contextMenuShow = true;
|
||||
contextMenuX = event.clientX;
|
||||
contextMenuY = event.clientY;
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenuShow = false;
|
||||
}
|
||||
|
||||
// Subscribe to theme changes
|
||||
const config = configStore.config;
|
||||
|
||||
// Light theme
|
||||
const lightTheme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
backgroundColor: "#ffffff",
|
||||
color: "#24292e",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: "#24292e",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "#24292e",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "#c8d3f5",
|
||||
},
|
||||
".cm-panels": {
|
||||
backgroundColor: "#f6f8fa",
|
||||
color: "#24292e",
|
||||
},
|
||||
".cm-panels.cm-panels-top": {
|
||||
borderBottom: "1px solid #e1e4e8",
|
||||
},
|
||||
".cm-panels.cm-panels-bottom": {
|
||||
borderTop: "1px solid #e1e4e8",
|
||||
},
|
||||
".cm-searchMatch": {
|
||||
backgroundColor: "#ffdf5d",
|
||||
outline: "1px solid #c4a000",
|
||||
},
|
||||
".cm-searchMatch.cm-searchMatch-selected": {
|
||||
backgroundColor: "#c4a000",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "#f6f8fa",
|
||||
},
|
||||
".cm-selectionMatch": {
|
||||
backgroundColor: "#c8d3f5",
|
||||
},
|
||||
".cm-matchingBracket, .cm-nonmatchingBracket": {
|
||||
backgroundColor: "#c8d3f5",
|
||||
outline: "1px solid #888",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#f6f8fa",
|
||||
color: "#6a737d",
|
||||
border: "none",
|
||||
borderRight: "1px solid #e1e4e8",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "#e1e4e8",
|
||||
},
|
||||
".cm-foldPlaceholder": {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
color: "#6a737d",
|
||||
},
|
||||
".cm-tooltip": {
|
||||
border: "1px solid #e1e4e8",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:before": {
|
||||
borderTopColor: "transparent",
|
||||
borderBottomColor: "transparent",
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:after": {
|
||||
borderTopColor: "#ffffff",
|
||||
borderBottomColor: "#ffffff",
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
"& > ul > li[aria-selected]": {
|
||||
backgroundColor: "#e1e4e8",
|
||||
color: "#24292e",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ dark: false }
|
||||
);
|
||||
|
||||
const lightHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#d73a49" },
|
||||
{
|
||||
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
||||
color: "#6f42c1",
|
||||
},
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: "#6f42c1" },
|
||||
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: "#005cc5" },
|
||||
{ tag: [tags.definition(tags.name), tags.separator], color: "#24292e" },
|
||||
{
|
||||
tag: [
|
||||
tags.typeName,
|
||||
tags.className,
|
||||
tags.number,
|
||||
tags.changed,
|
||||
tags.annotation,
|
||||
tags.modifier,
|
||||
tags.self,
|
||||
tags.namespace,
|
||||
],
|
||||
color: "#e36209",
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
tags.operator,
|
||||
tags.operatorKeyword,
|
||||
tags.url,
|
||||
tags.escape,
|
||||
tags.regexp,
|
||||
tags.link,
|
||||
tags.special(tags.string),
|
||||
],
|
||||
color: "#032f62",
|
||||
},
|
||||
{ tag: [tags.meta, tags.comment], color: "#6a737d" },
|
||||
{ tag: tags.strong, fontWeight: "bold" },
|
||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: tags.link, color: "#032f62", textDecoration: "underline" },
|
||||
{ tag: tags.heading, fontWeight: "bold", color: "#005cc5" },
|
||||
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: "#005cc5" },
|
||||
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: "#22863a" },
|
||||
{ tag: tags.invalid, color: "#cb2431" },
|
||||
]);
|
||||
|
||||
// High contrast theme
|
||||
const highContrastTheme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: "#ffffff",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "#ffffff",
|
||||
borderLeftWidth: "2px",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "#264f78",
|
||||
},
|
||||
".cm-panels": {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
},
|
||||
".cm-panels.cm-panels-top": {
|
||||
borderBottom: "2px solid #ffffff",
|
||||
},
|
||||
".cm-panels.cm-panels-bottom": {
|
||||
borderTop: "2px solid #ffffff",
|
||||
},
|
||||
".cm-searchMatch": {
|
||||
backgroundColor: "#515c6a",
|
||||
outline: "2px solid #ffff00",
|
||||
},
|
||||
".cm-searchMatch.cm-searchMatch-selected": {
|
||||
backgroundColor: "#ffff00",
|
||||
color: "#000000",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "#1a1a1a",
|
||||
},
|
||||
".cm-selectionMatch": {
|
||||
backgroundColor: "#264f78",
|
||||
},
|
||||
".cm-matchingBracket, .cm-nonmatchingBracket": {
|
||||
backgroundColor: "#515c6a",
|
||||
outline: "2px solid #ffff00",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#000000",
|
||||
color: "#858585",
|
||||
border: "none",
|
||||
borderRight: "2px solid #ffffff",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "#1a1a1a",
|
||||
color: "#ffffff",
|
||||
},
|
||||
".cm-foldPlaceholder": {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
color: "#ffff00",
|
||||
},
|
||||
".cm-tooltip": {
|
||||
border: "2px solid #ffffff",
|
||||
backgroundColor: "#000000",
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:before": {
|
||||
borderTopColor: "transparent",
|
||||
borderBottomColor: "transparent",
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:after": {
|
||||
borderTopColor: "#000000",
|
||||
borderBottomColor: "#000000",
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
"& > ul > li[aria-selected]": {
|
||||
backgroundColor: "#264f78",
|
||||
color: "#ffffff",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
);
|
||||
|
||||
const highContrastHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#569cd6", fontWeight: "bold" },
|
||||
{
|
||||
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
||||
color: "#9cdcfe",
|
||||
},
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: "#dcdcaa" },
|
||||
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: "#4fc1ff" },
|
||||
{ tag: [tags.definition(tags.name), tags.separator], color: "#ffffff" },
|
||||
{
|
||||
tag: [
|
||||
tags.typeName,
|
||||
tags.className,
|
||||
tags.number,
|
||||
tags.changed,
|
||||
tags.annotation,
|
||||
tags.modifier,
|
||||
tags.self,
|
||||
tags.namespace,
|
||||
],
|
||||
color: "#4ec9b0",
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
tags.operator,
|
||||
tags.operatorKeyword,
|
||||
tags.url,
|
||||
tags.escape,
|
||||
tags.regexp,
|
||||
tags.link,
|
||||
tags.special(tags.string),
|
||||
],
|
||||
color: "#d4d4d4",
|
||||
},
|
||||
{ tag: [tags.meta, tags.comment], color: "#6a9955" },
|
||||
{ tag: tags.strong, fontWeight: "bold" },
|
||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: tags.link, color: "#3794ff", textDecoration: "underline" },
|
||||
{ tag: tags.heading, fontWeight: "bold", color: "#569cd6" },
|
||||
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: "#569cd6" },
|
||||
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: "#ce9178" },
|
||||
{ tag: tags.invalid, color: "#f44747" },
|
||||
]);
|
||||
|
||||
function getThemeExtension(theme: string): Extension {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return [lightTheme, syntaxHighlighting(lightHighlightStyle)];
|
||||
case "high-contrast":
|
||||
return [highContrastTheme, syntaxHighlighting(highContrastHighlightStyle)];
|
||||
case "dark":
|
||||
case "custom":
|
||||
default:
|
||||
return oneDark;
|
||||
}
|
||||
}
|
||||
|
||||
function getLanguageExtension(language: string): Extension {
|
||||
const languageMap: Record<string, () => Extension> = {
|
||||
javascript: () => javascript({ jsx: true, typescript: false }),
|
||||
typescript: () => javascript({ jsx: true, typescript: true }),
|
||||
python: () => python(),
|
||||
rust: () => rust(),
|
||||
html: () => html(),
|
||||
css: () => css(),
|
||||
json: () => json(),
|
||||
markdown: () => markdown(),
|
||||
xml: () => xml(),
|
||||
sql: () => sql(),
|
||||
java: () => java(),
|
||||
c: () => cpp(),
|
||||
cpp: () => cpp(),
|
||||
csharp: () => cpp(),
|
||||
php: () => php(),
|
||||
go: () => go(),
|
||||
yaml: () => yaml(),
|
||||
scss: () => sass(),
|
||||
sass: () => sass(),
|
||||
less: () => less(),
|
||||
vue: () => vue(),
|
||||
angular: () => angular(),
|
||||
wasm: () => wast(),
|
||||
shell: () => StreamLanguage.define(shell),
|
||||
ruby: () => StreamLanguage.define(ruby),
|
||||
swift: () => StreamLanguage.define(swift),
|
||||
lua: () => StreamLanguage.define(lua),
|
||||
r: () => StreamLanguage.define(r),
|
||||
toml: () => StreamLanguage.define(toml),
|
||||
dockerfile: () => StreamLanguage.define(dockerFile),
|
||||
powershell: () => StreamLanguage.define(powerShell),
|
||||
svelte: () => html(),
|
||||
};
|
||||
|
||||
const getExtension = languageMap[language];
|
||||
return getExtension ? getExtension() : [];
|
||||
}
|
||||
|
||||
function createEditor() {
|
||||
if (!editorContainer) return;
|
||||
|
||||
const currentTheme = $config.theme;
|
||||
|
||||
const saveKeymap = keymap.of([
|
||||
{
|
||||
key: "Mod-s",
|
||||
run: () => {
|
||||
editorStore.saveFile(tab.id);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const content = update.state.doc.toString();
|
||||
editorStore.updateTabContent(tab.id, content);
|
||||
}
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: tab.content,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
themeCompartment.of(getThemeExtension(currentTheme)),
|
||||
getLanguageExtension(tab.language),
|
||||
saveKeymap,
|
||||
updateListener,
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
},
|
||||
".cm-content": {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
view = new EditorView({
|
||||
state,
|
||||
parent: editorContainer,
|
||||
});
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
if (view) {
|
||||
view.destroy();
|
||||
view = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for theme changes and update the editor
|
||||
$: if (view && $config.theme) {
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(getThemeExtension($config.theme)),
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
createEditor();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroyEditor();
|
||||
});
|
||||
|
||||
$: if (view && tab.content !== view.state.doc.toString()) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: tab.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="code-editor" bind:this={editorContainer} oncontextmenu={handleContextMenu}></div>
|
||||
|
||||
{#if contextMenuShow && view}
|
||||
<EditorContextMenu
|
||||
x={contextMenuX}
|
||||
y={contextMenuY}
|
||||
editorView={view}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.code-editor {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-terminal);
|
||||
}
|
||||
|
||||
.code-editor :global(.cm-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.code-editor :global(.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,131 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
message,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props = $props();
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
} else if (event.key === "Enter") {
|
||||
onConfirm();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="dialog-overlay" onclick={onCancel}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
||||
<h2 class="dialog-title">{title}</h2>
|
||||
<p class="dialog-message">{message}</p>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button class="btn-cancel" onclick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button class="btn-confirm" class:destructive onclick={onConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
margin: 0 1rem;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-primary);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background-color: var(--accent-primary);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-confirm:hover {
|
||||
background-color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.btn-confirm.destructive {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-confirm.destructive:hover {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
@@ -1,238 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
editorView: EditorView;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { x, y, editorView, onClose }: Props = $props();
|
||||
|
||||
// Menu element reference for measuring
|
||||
let menuElement: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Adjusted position to keep menu within viewport
|
||||
let adjustedX = $derived.by(() => {
|
||||
if (!menuElement) return x;
|
||||
const menuWidth = menuElement.offsetWidth || 180;
|
||||
const maxX = window.innerWidth - menuWidth - 8;
|
||||
return Math.min(x, maxX);
|
||||
});
|
||||
|
||||
let adjustedY = $derived.by(() => {
|
||||
if (!menuElement) return y;
|
||||
const menuHeight = menuElement.offsetHeight || 250;
|
||||
const maxY = window.innerHeight - menuHeight - 8;
|
||||
return Math.min(y, maxY);
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function execCommand(command: "cut" | "copy" | "paste" | "selectAll" | "undo" | "redo") {
|
||||
editorView.focus();
|
||||
|
||||
switch (command) {
|
||||
case "cut":
|
||||
document.execCommand("cut");
|
||||
break;
|
||||
case "copy":
|
||||
document.execCommand("copy");
|
||||
break;
|
||||
case "paste":
|
||||
document.execCommand("paste");
|
||||
break;
|
||||
case "selectAll":
|
||||
editorView.dispatch({
|
||||
selection: { anchor: 0, head: editorView.state.doc.length },
|
||||
});
|
||||
break;
|
||||
case "undo":
|
||||
import("@codemirror/commands").then(({ undo }) => {
|
||||
undo(editorView);
|
||||
});
|
||||
break;
|
||||
case "redo":
|
||||
import("@codemirror/commands").then(({ redo }) => {
|
||||
redo(editorView);
|
||||
});
|
||||
break;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Check if there's a selection
|
||||
let hasSelection = $derived(
|
||||
editorView.state.selection.main.from !== editorView.state.selection.main.to
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="menu-overlay"
|
||||
onclick={onClose}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="menu-content"
|
||||
style="left: {adjustedX}px; top: {adjustedY}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button class="menu-item" onclick={() => execCommand("undo")}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 10h10a5 5 0 0 1 5 5v2M3 10l4-4M3 10l4 4"
|
||||
/>
|
||||
</svg>
|
||||
Undo
|
||||
<span class="shortcut">Ctrl+Z</span>
|
||||
</button>
|
||||
|
||||
<button class="menu-item" onclick={() => execCommand("redo")}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 10H11a5 5 0 0 0-5 5v2M21 10l-4-4M21 10l-4 4"
|
||||
/>
|
||||
</svg>
|
||||
Redo
|
||||
<span class="shortcut">Ctrl+Y</span>
|
||||
</button>
|
||||
|
||||
<div class="menu-divider"></div>
|
||||
|
||||
<button class="menu-item" onclick={() => execCommand("cut")} disabled={!hasSelection}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.121 14.121L19 19m-7-7l7-7m-7 7l-2.879 2.879M12 12L9.121 9.121m0 5.758a3 3 0 1 0-4.243-4.243 3 3 0 0 0 4.243 4.243zm0-5.758a3 3 0 1 0-4.243-4.243 3 3 0 0 0 4.243 4.243z"
|
||||
/>
|
||||
</svg>
|
||||
Cut
|
||||
<span class="shortcut">Ctrl+X</span>
|
||||
</button>
|
||||
|
||||
<button class="menu-item" onclick={() => execCommand("copy")} disabled={!hasSelection}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2m-6 12h8a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Copy
|
||||
<span class="shortcut">Ctrl+C</span>
|
||||
</button>
|
||||
|
||||
<button class="menu-item" onclick={() => execCommand("paste")}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2"
|
||||
/>
|
||||
</svg>
|
||||
Paste
|
||||
<span class="shortcut">Ctrl+V</span>
|
||||
</button>
|
||||
|
||||
<div class="menu-divider"></div>
|
||||
|
||||
<button class="menu-item" onclick={() => execCommand("selectAll")}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9h6v6H9z" />
|
||||
</svg>
|
||||
Select All
|
||||
<span class="shortcut">Ctrl+A</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
min-width: 180px;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 0.25rem 0;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover:not(:disabled) {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.menu-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
margin: 0.25rem 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,253 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import FileBrowser from "./FileBrowser.svelte";
|
||||
import EditorTabs from "./EditorTabs.svelte";
|
||||
import CodeEditor from "./CodeEditor.svelte";
|
||||
|
||||
const isFileBrowserOpen = editorStore.isFileBrowserOpen;
|
||||
const activeTab = editorStore.activeTab;
|
||||
const saveError = editorStore.saveError;
|
||||
|
||||
function toggleFileBrowser() {
|
||||
editorStore.toggleFileBrowser();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await editorStore.saveFile();
|
||||
} catch {
|
||||
// Error is already set in the store
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="toolbar">
|
||||
<button
|
||||
class="toolbar-button"
|
||||
class:active={$isFileBrowserOpen}
|
||||
on:click={toggleFileBrowser}
|
||||
title="Toggle file browser (Ctrl+B)"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="toolbar-spacer"></div>
|
||||
|
||||
{#if $activeTab}
|
||||
<button class="toolbar-button" on:click={handleSave} title="Save (Ctrl+S)">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $saveError}
|
||||
<div class="error-banner">
|
||||
<span>{$saveError}</span>
|
||||
<button
|
||||
class="dismiss-button"
|
||||
on:click={() => {}}
|
||||
title="Dismiss error"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="editor-content">
|
||||
{#if $isFileBrowserOpen}
|
||||
<div class="file-browser-container">
|
||||
<FileBrowser />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="editor-main">
|
||||
<EditorTabs />
|
||||
|
||||
<div class="editor-area">
|
||||
{#if $activeTab}
|
||||
{#key $activeTab.id}
|
||||
<CodeEditor tab={$activeTab} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="no-file">
|
||||
<div class="no-file-content">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<p>Select a file to edit</p>
|
||||
<p class="hint">Use the file browser on the left or press Ctrl+B to toggle it</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-button.active {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #ff000022;
|
||||
border-bottom: 1px solid #ff0000;
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dismiss-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ff6b6b;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dismiss-button:hover {
|
||||
background-color: #ff000033;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-browser-container {
|
||||
width: 250px;
|
||||
min-width: 150px;
|
||||
max-width: 400px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-area {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.no-file {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bg-terminal);
|
||||
}
|
||||
|
||||
.no-file-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-file-content svg {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-file-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-file-content .hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,170 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
|
||||
const tabs = editorStore.tabs;
|
||||
const activeTabId = editorStore.activeTabId;
|
||||
|
||||
function handleTabClick(tabId: string) {
|
||||
editorStore.setActiveTab(tabId);
|
||||
}
|
||||
|
||||
function handleCloseTab(event: MouseEvent, tabId: string) {
|
||||
event.stopPropagation();
|
||||
editorStore.closeTab(tabId);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent, tabId: string) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
editorStore.setActiveTab(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseKeydown(event: KeyboardEvent, tabId: string) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
editorStore.closeTab(tabId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor-tabs">
|
||||
{#if $tabs.length === 0}
|
||||
<div class="no-tabs">No files open</div>
|
||||
{:else}
|
||||
<div class="tabs-container" role="tablist">
|
||||
{#each $tabs as tab (tab.id)}
|
||||
<div
|
||||
class="tab"
|
||||
class:active={tab.id === $activeTabId}
|
||||
class:dirty={tab.isDirty}
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
aria-selected={tab.id === $activeTabId}
|
||||
on:click={() => handleTabClick(tab.id)}
|
||||
on:keydown={(e) => handleKeydown(e, tab.id)}
|
||||
title={tab.filePath}
|
||||
>
|
||||
<span class="tab-name">
|
||||
{tab.fileName}
|
||||
{#if tab.isDirty}
|
||||
<span class="dirty-indicator">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
class="close-button"
|
||||
on:click={(e) => handleCloseTab(e, tab.id)}
|
||||
on:keydown={(e) => handleCloseKeydown(e, tab.id)}
|
||||
title="Close tab"
|
||||
aria-label="Close {tab.fileName}"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.no-tabs {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab:focus {
|
||||
outline: 1px solid var(--accent-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background-color: var(--bg-terminal);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--accent-primary);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dirty-indicator {
|
||||
color: var(--accent-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover .close-button,
|
||||
.tab.active .close-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab.dirty .close-button {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,358 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import FileTreeItem from "./FileTreeItem.svelte";
|
||||
import FileContextMenu from "./FileContextMenu.svelte";
|
||||
import InputDialog from "./InputDialog.svelte";
|
||||
import ConfirmDialog from "./ConfirmDialog.svelte";
|
||||
import type { FileEntry } from "$lib/types/editor";
|
||||
|
||||
const fileTree = editorStore.fileTree;
|
||||
const isLoadingTree = editorStore.isLoadingTree;
|
||||
const currentDirectory = editorStore.currentDirectory;
|
||||
|
||||
// Listen for Ctrl+N keyboard shortcut from +page.svelte
|
||||
function handleNewFileEvent() {
|
||||
handleNewFile();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("editor-new-file", handleNewFileEvent);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener("editor-new-file", handleNewFileEvent);
|
||||
});
|
||||
|
||||
// Context menu state
|
||||
let contextMenu = $state<{
|
||||
show: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
targetEntry: FileEntry | null;
|
||||
}>({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
targetEntry: null,
|
||||
});
|
||||
|
||||
// Dialog state
|
||||
let dialog = $state<{
|
||||
type: "newFile" | "newFolder" | "delete" | "rename" | null;
|
||||
parentPath: string;
|
||||
targetEntry: FileEntry | null;
|
||||
}>({
|
||||
type: null,
|
||||
parentPath: "",
|
||||
targetEntry: null,
|
||||
});
|
||||
|
||||
function handleRefresh() {
|
||||
const dir = $currentDirectory;
|
||||
if (dir) {
|
||||
editorStore.initializeFileTree(dir);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(event: MouseEvent, entry: FileEntry | null = null) {
|
||||
event.preventDefault();
|
||||
contextMenu = {
|
||||
show: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
targetEntry: entry,
|
||||
};
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = { show: false, x: 0, y: 0, targetEntry: null };
|
||||
}
|
||||
|
||||
function openNewFileDialog(parentPath: string) {
|
||||
dialog = { type: "newFile", parentPath, targetEntry: null };
|
||||
}
|
||||
|
||||
function openNewFolderDialog(parentPath: string) {
|
||||
dialog = { type: "newFolder", parentPath, targetEntry: null };
|
||||
}
|
||||
|
||||
function openDeleteDialog(entry: FileEntry) {
|
||||
dialog = { type: "delete", parentPath: "", targetEntry: entry };
|
||||
}
|
||||
|
||||
function openRenameDialog(entry: FileEntry) {
|
||||
dialog = { type: "rename", parentPath: "", targetEntry: entry };
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialog = { type: null, parentPath: "", targetEntry: null };
|
||||
}
|
||||
|
||||
async function handleCreateFile(fileName: string) {
|
||||
await editorStore.createFile(dialog.parentPath, fileName);
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
async function handleCreateFolder(folderName: string) {
|
||||
await editorStore.createDirectory(dialog.parentPath, folderName);
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!dialog.targetEntry) return;
|
||||
|
||||
if (dialog.targetEntry.isDirectory) {
|
||||
await editorStore.deleteDirectory(dialog.targetEntry.path);
|
||||
} else {
|
||||
await editorStore.deleteFile(dialog.targetEntry.path);
|
||||
}
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
async function handleRename(newName: string) {
|
||||
if (!dialog.targetEntry) return;
|
||||
await editorStore.renamePath(dialog.targetEntry.path, newName);
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
function handleNewFile() {
|
||||
openNewFileDialog($currentDirectory);
|
||||
}
|
||||
|
||||
function handleNewFolder() {
|
||||
openNewFolderDialog($currentDirectory);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="file-browser" oncontextmenu={(e) => handleContextMenu(e, null)}>
|
||||
<div class="header">
|
||||
<span class="title">Files</span>
|
||||
<div class="header-buttons">
|
||||
<button class="header-button" onclick={handleNewFile} title="New File (Ctrl+N)">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="M12 18v-6" />
|
||||
<path d="M9 15h6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-button" onclick={handleNewFolder} title="New Folder">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
<path d="M12 11v6" />
|
||||
<path d="M9 14h6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-button" onclick={handleRefresh} title="Refresh file tree">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M23 4v6h-6" />
|
||||
<path d="M1 20v-6h6" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tree-container">
|
||||
{#if $isLoadingTree}
|
||||
<div class="loading">
|
||||
<svg
|
||||
class="spinner"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="32">
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
dur="1s"
|
||||
values="32;0"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
{:else if $fileTree.length === 0}
|
||||
<div class="empty">
|
||||
<span>No files found</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="tree">
|
||||
{#each $fileTree as entry (entry.path)}
|
||||
<FileTreeItem {entry} onContextMenu={handleContextMenu} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if contextMenu.show}
|
||||
<FileContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
targetEntry={contextMenu.targetEntry}
|
||||
currentDirectory={$currentDirectory}
|
||||
onNewFile={openNewFileDialog}
|
||||
onNewFolder={openNewFolderDialog}
|
||||
onRename={openRenameDialog}
|
||||
onDelete={openDeleteDialog}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if dialog.type === "newFile"}
|
||||
<InputDialog
|
||||
title="New File"
|
||||
placeholder="Enter file name..."
|
||||
confirmText="Create"
|
||||
onConfirm={handleCreateFile}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if dialog.type === "newFolder"}
|
||||
<InputDialog
|
||||
title="New Folder"
|
||||
placeholder="Enter folder name..."
|
||||
confirmText="Create"
|
||||
onConfirm={handleCreateFolder}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if dialog.type === "delete" && dialog.targetEntry}
|
||||
<ConfirmDialog
|
||||
title="Delete {dialog.targetEntry.isDirectory ? 'Folder' : 'File'}"
|
||||
message="Are you sure you want to delete '{dialog.targetEntry.name}'? {dialog.targetEntry
|
||||
.isDirectory
|
||||
? 'This will also delete all files and folders inside it.'
|
||||
: 'This action cannot be undone.'}"
|
||||
confirmText="Delete"
|
||||
destructive={true}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if dialog.type === "rename" && dialog.targetEntry}
|
||||
<InputDialog
|
||||
title="Rename {dialog.targetEntry.isDirectory ? 'Folder' : 'File'}"
|
||||
placeholder="Enter new name..."
|
||||
confirmText="Rename"
|
||||
initialValue={dialog.targetEntry.name}
|
||||
onConfirm={handleRename}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.file-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.header-button:hover {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.tree {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,203 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { FileEntry } from "$lib/types/editor";
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
targetEntry: FileEntry | null;
|
||||
currentDirectory: string;
|
||||
onNewFile: (parentPath: string) => void;
|
||||
onNewFolder: (parentPath: string) => void;
|
||||
onRename: (entry: FileEntry) => void;
|
||||
onDelete: (entry: FileEntry) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
x,
|
||||
y,
|
||||
targetEntry,
|
||||
currentDirectory,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onRename,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
|
||||
function handleNewFile() {
|
||||
const parentPath = targetEntry?.isDirectory ? targetEntry.path : currentDirectory;
|
||||
onNewFile(parentPath);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleNewFolder() {
|
||||
const parentPath = targetEntry?.isDirectory ? targetEntry.path : currentDirectory;
|
||||
onNewFolder(parentPath);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleRename() {
|
||||
if (targetEntry) {
|
||||
onRename(targetEntry);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (targetEntry) {
|
||||
onDelete(targetEntry);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
// Menu element reference for measuring
|
||||
let menuElement: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Adjusted position to keep menu within viewport
|
||||
let adjustedX = $derived.by(() => {
|
||||
if (!menuElement) return x;
|
||||
const menuWidth = menuElement.offsetWidth || 160;
|
||||
const maxX = window.innerWidth - menuWidth - 8;
|
||||
return Math.min(x, maxX);
|
||||
});
|
||||
|
||||
let adjustedY = $derived.by(() => {
|
||||
if (!menuElement) return y;
|
||||
const menuHeight = menuElement.offsetHeight || 200;
|
||||
const maxY = window.innerHeight - menuHeight - 8;
|
||||
return Math.min(y, maxY);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="menu-overlay"
|
||||
onclick={onClose}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="menu-content"
|
||||
style="left: {adjustedX}px; top: {adjustedY}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button class="menu-item" onclick={handleNewFile}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
New File
|
||||
</button>
|
||||
|
||||
<button class="menu-item" onclick={handleNewFolder}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
New Folder
|
||||
</button>
|
||||
|
||||
{#if targetEntry}
|
||||
<div class="menu-divider"></div>
|
||||
|
||||
<button class="menu-item" onclick={handleRename}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
|
||||
<button class="menu-item destructive" onclick={handleDelete}>
|
||||
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Delete {targetEntry.isDirectory ? "Folder" : "File"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.menu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
min-width: 160px;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 0.25rem 0;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.menu-item.destructive {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
margin: 0.25rem 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||