Compare commits

...

11 Commits

Author SHA1 Message Date
minori cb1b593887 deps: update codemirror to 6.65.7
CI / Lint & Test (pull_request) Failing after 7m29s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 2m30s
2026-02-04 08:55:00 -08:00
naomi daedbfd865 release: v1.1.1
CI / Lint & Test (push) Successful in 16m1s
CI / Build Linux (push) Successful in 19m55s
CI / Build Windows (cross-compile) (push) Successful in 30m58s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m56s
2026-01-29 16:57:27 -08:00
naomi 7093e58fe4 fix: capture accurate usage (#80)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
CI / Lint & Test (push) Successful in 16m1s
CI / Build Linux (push) Successful in 19m21s
CI / Build Windows (cross-compile) (push) Successful in 29m6s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #80
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-29 13:34:38 -08:00
naomi cab759ec61 release: v1.1.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint & Test (push) Successful in 16m14s
CI / Build Linux (push) Successful in 19m54s
CI / Build Windows (cross-compile) (push) Successful in 29m3s
2026-01-28 18:22:24 -08:00
hikari e45a1a1c98 feat: add built-in file editor with syntax highlighting (#79)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
## Summary
- Add CodeMirror 6 editor with syntax highlighting for 40+ languages
- Add file browser sidebar with collapsible directory tree navigation
- Add multi-tab support with dirty state indicators and close buttons
- Add keyboard shortcuts (Ctrl+E toggle, Ctrl+B file browser, Ctrl+S save, Ctrl+W close tab)
- Add editor toggle button to status bar (disabled when not connected)
- Editor automatically uses current session's working directory
- Add Tauri backend commands for file operations (list_directory, read_file_content, write_file_content)

## Test Plan
- [ ] Connect to a session and verify the editor toggle button becomes enabled
- [ ] Press Ctrl+E to open the editor and verify file tree shows the session's CWD
- [ ] Navigate directories and open files to verify syntax highlighting works
- [ ] Edit a file and verify the dirty indicator (*) appears
- [ ] Save with Ctrl+S and verify the dirty indicator disappears
- [ ] Open multiple files and verify tab switching works
- [ ] Close tabs with Ctrl+W or the X button
- [ ] Disconnect and verify the editor automatically closes
- [ ] Verify keyboard shortcuts are documented in the shortcuts modal

Closes #72

✨ This PR was created with help from Hikari~ 🌸

Reviewed-on: #79
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-01-28 18:20:02 -08:00
naomi edc863e020 feat: add copy buttons to user and assistant messages (#78)
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 5m26s
CI / Lint & Test (push) Successful in 20m11s
CI / Build Linux (push) Successful in 20m45s
CI / Build Windows (cross-compile) (push) Successful in 28m32s
Closes #74

Reviewed-on: #78
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-28 15:20:37 -08:00
naomi b006f571bf feat: icon (#77)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / Lint & Test (push) Successful in 16m10s
CI / Build Linux (push) Successful in 19m44s
CI / Build Windows (cross-compile) (push) Successful in 29m42s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #77
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-28 12:51:05 -08:00
naomi ea3cc8b26c feat: enable markdown rendering for user messages (#76)
CI / Lint & Test (push) Has been cancelled
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #76
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-28 12:50:50 -08:00
hikari 2bb541fba6 docs: update README to standard template
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint & Test (push) Successful in 16m44s
CI / Build Linux (push) Successful in 19m42s
CI / Build Windows (cross-compile) (push) Successful in 30m32s
2026-01-26 12:42:43 -08:00
naomi bebf1552a6 release: v1.0.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 50s
CI / Lint & Test (push) Successful in 15m58s
CI / Build Linux (push) Successful in 19m8s
CI / Build Windows (cross-compile) (push) Successful in 28m41s
2026-01-26 00:33:17 -08:00
naomi b3d79a82ef feat: add tests and assert coverage (#71)
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #71
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-26 00:26:03 -08:00
99 changed files with 11301 additions and 96 deletions
+8 -5
View File
@@ -49,13 +49,13 @@ jobs:
- name: Run Svelte Check - name: Run Svelte Check
run: pnpm check run: pnpm check
- name: Run frontend tests - name: Run frontend tests with coverage
run: pnpm test run: pnpm test:coverage
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
components: clippy components: clippy, llvm-tools-preview
- name: Cache Rust dependencies - name: Cache Rust dependencies
uses: actions/cache@v4 uses: actions/cache@v4
@@ -68,13 +68,16 @@ jobs:
src-tauri/target/ src-tauri/target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install cargo-llvm-cov
run: cargo install cargo-llvm-cov --locked
- name: Run Clippy - name: Run Clippy
working-directory: src-tauri working-directory: src-tauri
run: cargo clippy --all-targets --all-features -- -D warnings run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run Rust tests - name: Run Rust tests with coverage
working-directory: src-tauri working-directory: src-tauri
run: cargo test run: cargo llvm-cov --fail-under-lines 50
build-linux: build-linux:
name: Build Linux name: Build Linux
+3
View File
@@ -8,3 +8,6 @@ node_modules
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Coverage reports
/coverage
+29 -1
View File
@@ -1 +1,29 @@
tem # 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`
-74
View File
@@ -1,74 +0,0 @@
# 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
+1 -1
View File
@@ -27,6 +27,6 @@ export default tseslint.config(
}, },
}, },
{ {
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/"], ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/", "coverage/"],
} }
); );
+33 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "0.3.0", "version": "1.1.1",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -16,6 +16,10 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "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": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
@@ -23,6 +27,32 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.8.1",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.11",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
@@ -32,6 +62,7 @@
"@tauri-apps/plugin-os": "^2", "@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4", "@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"codemirror": "6.65.7",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"marked": "^17.0.1" "marked": "^17.0.1"
}, },
@@ -44,6 +75,7 @@
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1", "@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0", "eslint-plugin-svelte": "^3.14.0",
+668
View File
@@ -8,6 +8,84 @@ importers:
.: .:
dependencies: 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': '@tauri-apps/api':
specifier: ^2 specifier: ^2
version: 2.9.1 version: 2.9.1
@@ -35,6 +113,9 @@ importers:
'@tauri-apps/plugin-store': '@tauri-apps/plugin-store':
specifier: ^2 specifier: ^2
version: 2.4.2 version: 2.4.2
codemirror:
specifier: 6.65.7
version: 6.65.7
highlight.js: highlight.js:
specifier: ^11.11.1 specifier: ^11.11.1
version: 11.11.1 version: 11.11.1
@@ -66,6 +147,9 @@ importers:
'@testing-library/svelte': '@testing-library/svelte':
specifier: ^5.3.1 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)) 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: eslint:
specifier: ^9.39.2 specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1) version: 9.39.2(jiti@2.6.1)
@@ -130,14 +214,112 @@ packages:
resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
engines: {node: '>=6.9.0'} 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': '@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'} 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': '@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'} 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/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': '@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -405,6 +587,60 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 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': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -869,6 +1105,15 @@ packages:
resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==} resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@vitest/expect@4.0.17':
resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==}
@@ -886,6 +1131,9 @@ packages:
'@vitest/pretty-format@4.0.17': '@vitest/pretty-format@4.0.17':
resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==} resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==}
'@vitest/pretty-format@4.0.18':
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
'@vitest/runner@4.0.17': '@vitest/runner@4.0.17':
resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==} resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==}
@@ -898,6 +1146,9 @@ packages:
'@vitest/utils@4.0.17': '@vitest/utils@4.0.17':
resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==} resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==}
'@vitest/utils@4.0.18':
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -941,6 +1192,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'} engines: {node: '>=12'}
ast-v8-to-istanbul@0.3.10:
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
axobject-query@4.1.0: axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -977,6 +1231,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
codemirror@6.65.7:
resolution: {integrity: sha512-HcfnUFJwI2FvH73YWVbbMh7ObWxZiHIycEhv9ZEXy6e8ZKDjtZKbbYFUtsLN46HFXPvU5V2Uvc2d55Z//oFW5A==}
deprecated: This is an accidentally mis-tagged instance of 5.65.7
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -991,6 +1249,9 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1211,6 +1472,9 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} 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: http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -1256,6 +1520,18 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 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: jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
@@ -1263,6 +1539,9 @@ packages:
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.1: js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true hasBin: true
@@ -1394,6 +1673,13 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 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: marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
@@ -1600,6 +1886,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'} engines: {node: '>=8'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
supports-color@7.2.0: supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1779,6 +2068,9 @@ packages:
jsdom: jsdom:
optional: true optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1869,10 +2161,227 @@ snapshots:
js-tokens: 4.0.0 js-tokens: 4.0.0
picocolors: 1.1.1 picocolors: 1.1.1
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.28.6':
dependencies:
'@babel/types': 7.28.6
'@babel/runtime@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/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/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)': '@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)':
@@ -2051,6 +2560,101 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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': {} '@polka/url@1.0.0-next.29': {}
'@rollup/rollup-android-arm-eabi@4.55.1': '@rollup/rollup-android-arm-eabi@4.55.1':
@@ -2469,6 +3073,20 @@ snapshots:
'@typescript-eslint/types': 8.53.0 '@typescript-eslint/types': 8.53.0
eslint-visitor-keys: 4.2.1 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': '@vitest/expect@4.0.17':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
@@ -2490,6 +3108,10 @@ snapshots:
dependencies: dependencies:
tinyrainbow: 3.0.3 tinyrainbow: 3.0.3
'@vitest/pretty-format@4.0.18':
dependencies:
tinyrainbow: 3.0.3
'@vitest/runner@4.0.17': '@vitest/runner@4.0.17':
dependencies: dependencies:
'@vitest/utils': 4.0.17 '@vitest/utils': 4.0.17
@@ -2508,6 +3130,11 @@ snapshots:
'@vitest/pretty-format': 4.0.17 '@vitest/pretty-format': 4.0.17
tinyrainbow: 3.0.3 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): acorn-jsx@5.3.2(acorn@8.15.0):
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0
@@ -2541,6 +3168,12 @@ snapshots:
assertion-error@2.0.1: {} 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: {} axobject-query@4.1.0: {}
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
@@ -2573,6 +3206,8 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
codemirror@6.65.7: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -2583,6 +3218,8 @@ snapshots:
cookie@0.6.0: {} cookie@0.6.0: {}
crelt@1.0.6: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -2823,6 +3460,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@exodus/crypto' - '@exodus/crypto'
html-escaper@2.0.2: {}
http-proxy-agent@7.0.2: http-proxy-agent@7.0.2:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
@@ -2864,10 +3503,25 @@ snapshots:
isexe@2.0.0: {} 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: {} jiti@2.6.1: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.1: js-yaml@4.1.1:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@@ -2986,6 +3640,16 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@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: {} marked@17.0.1: {}
mdn-data@2.12.2: {} mdn-data@2.12.2: {}
@@ -3172,6 +3836,8 @@ snapshots:
strip-json-comments@3.1.1: {} strip-json-comments@3.1.1: {}
style-mod@4.1.3: {}
supports-color@7.2.0: supports-color@7.2.0:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
@@ -3331,6 +3997,8 @@ snapshots:
- tsx - tsx
- yaml - yaml
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
dependencies: dependencies:
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0
+1 -1
View File
@@ -1602,7 +1602,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hikari-desktop" name = "hikari-desktop"
version = "0.3.0" version = "1.1.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"parking_lot", "parking_lot",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "0.3.0" version = "1.1.1"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because it is too large Load Diff
+465
View File
@@ -257,3 +257,468 @@ pub fn update_clipboard_language(
save_history(&app, &history)?; save_history(&app, &history)?;
Ok(updated_entry) 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));
}
}
+422
View File
@@ -393,3 +393,425 @@ pub async fn get_file_size(file_path: String) -> Result<u64, String> {
.map_err(|e| format!("Failed to get file metadata: {}", e))?; .map_err(|e| format!("Failed to get file metadata: {}", e))?;
Ok(metadata.len()) 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"));
}
}
+590
View File
@@ -286,3 +286,593 @@ 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> { pub fn git_create_branch(working_dir: String, branch_name: String) -> Result<String, String> {
run_git_command(&working_dir, &["checkout", "-b", &branch_name]) 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());
}
}
+8
View File
@@ -151,6 +151,14 @@ pub fn run() {
search_clipboard_entries, search_clipboard_entries,
get_clipboard_languages, get_clipboard_languages,
update_clipboard_language, update_clipboard_language,
list_directory,
read_file_content,
write_file_content,
create_file,
create_directory,
delete_file,
delete_directory,
rename_path,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+182
View File
@@ -171,6 +171,18 @@ pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> {
mod tests { mod tests {
use super::*; 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] #[test]
fn test_default_quick_actions_exist() { fn test_default_quick_actions_exist() {
let defaults = get_default_quick_actions(); let defaults = get_default_quick_actions();
@@ -188,4 +200,174 @@ mod tests {
assert!(!action.icon.is_empty()); assert!(!action.icon.is_empty());
} }
} }
#[test]
fn test_default_quick_actions_count() {
let defaults = get_default_quick_actions();
// Should have 6 default actions
assert_eq!(defaults.len(), 6);
}
#[test]
fn test_default_quick_actions_have_unique_ids() {
let defaults = get_default_quick_actions();
let mut ids: Vec<&String> = defaults.iter().map(|a| &a.id).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), defaults.len());
}
#[test]
fn test_default_quick_actions_ids_start_with_default() {
let defaults = get_default_quick_actions();
assert!(defaults.iter().all(|a| a.id.starts_with("default-")));
}
#[test]
fn test_quick_action_serialization() {
let action = create_test_action("test-1", "Test Action", false);
let json = serde_json::to_string(&action).expect("Failed to serialize");
let parsed: QuickAction = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, action.id);
assert_eq!(parsed.name, action.name);
assert_eq!(parsed.prompt, action.prompt);
assert_eq!(parsed.icon, action.icon);
assert_eq!(parsed.is_default, action.is_default);
}
#[test]
fn test_quick_action_clone() {
let original = create_test_action("clone-test", "Clone Test", true);
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.name, cloned.name);
assert_eq!(original.is_default, cloned.is_default);
}
#[test]
#[allow(clippy::useless_vec)]
fn test_quick_action_sorting_defaults_first() {
let mut actions = vec![
create_test_action("custom-z", "Zebra", false),
create_test_action("default-a", "Apple", true),
create_test_action("custom-a", "Alpha", false),
create_test_action("default-z", "Zulu", true),
];
// Sort by: defaults first, then alphabetically by name
actions.sort_by(|a, b| {
let default_cmp = b.is_default.cmp(&a.is_default);
if default_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
default_cmp
}
});
// Defaults should come first
assert!(actions[0].is_default);
assert!(actions[1].is_default);
assert!(!actions[2].is_default);
assert!(!actions[3].is_default);
// Within defaults, alphabetically sorted
assert_eq!(actions[0].name, "Apple");
assert_eq!(actions[1].name, "Zulu");
// Within non-defaults, alphabetically sorted
assert_eq!(actions[2].name, "Alpha");
assert_eq!(actions[3].name, "Zebra");
}
#[test]
fn test_known_default_actions() {
let defaults = get_default_quick_actions();
let ids: Vec<&str> = defaults.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"default-review-pr"));
assert!(ids.contains(&"default-run-tests"));
assert!(ids.contains(&"default-explain-file"));
assert!(ids.contains(&"default-fix-error"));
assert!(ids.contains(&"default-write-tests"));
assert!(ids.contains(&"default-refactor"));
}
#[test]
fn test_default_action_icons() {
let defaults = get_default_quick_actions();
let icons: Vec<&str> = defaults.iter().map(|a| a.icon.as_str()).collect();
assert!(icons.contains(&"git-pull-request"));
assert!(icons.contains(&"play"));
assert!(icons.contains(&"file-text"));
assert!(icons.contains(&"alert-circle"));
assert!(icons.contains(&"check-square"));
assert!(icons.contains(&"refresh-cw"));
}
#[test]
fn test_quick_action_prompts_not_empty() {
let defaults = get_default_quick_actions();
for action in defaults {
assert!(
action.prompt.len() > 10,
"Prompt should be meaningful: {}",
action.name
);
}
}
#[test]
fn test_quick_action_timestamps() {
let action = create_test_action("time-test", "Time Test", false);
assert!(action.created_at <= action.updated_at);
}
#[test]
fn test_default_actions_have_same_timestamps() {
let defaults = get_default_quick_actions();
// All defaults are created at the same instant
let first_created = defaults[0].created_at;
let first_updated = defaults[0].updated_at;
for action in &defaults {
assert_eq!(action.created_at, first_created);
assert_eq!(action.updated_at, first_updated);
}
}
#[test]
fn test_action_retain_non_default() {
let mut actions = vec![
create_test_action("default-1", "Default 1", true),
create_test_action("custom-1", "Custom 1", false),
create_test_action("default-2", "Default 2", true),
create_test_action("custom-2", "Custom 2", false),
];
// Mimics reset_default_quick_actions behavior (retain non-defaults)
actions.retain(|a| !a.is_default);
assert_eq!(actions.len(), 2);
assert!(actions.iter().all(|a| !a.is_default));
}
#[test]
#[allow(clippy::useless_vec)]
fn test_action_find_by_id() {
let actions = vec![
create_test_action("action-1", "First", false),
create_test_action("action-2", "Second", false),
create_test_action("action-3", "Third", false),
];
let found = actions.iter().find(|a| a.id == "action-2");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "Second");
let not_found = actions.iter().find(|a| a.id == "action-999");
assert!(not_found.is_none());
}
} }
+207
View File
@@ -145,6 +145,30 @@ pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn test_session_list_item_from_saved_session() { fn test_session_list_item_from_saved_session() {
@@ -164,4 +188,187 @@ mod tests {
assert_eq!(item.name, "Test Session"); assert_eq!(item.name, "Test Session");
assert_eq!(item.message_count, 5); 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);
}
} }
+213
View File
@@ -205,6 +205,19 @@ pub async fn reset_default_snippets(app: AppHandle) -> Result<(), String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn test_default_snippets_exist() { fn test_default_snippets_exist() {
@@ -223,4 +236,204 @@ mod tests {
assert!(!snippet.category.is_empty()); assert!(!snippet.category.is_empty());
} }
} }
#[test]
fn test_default_snippets_count() {
let defaults = get_default_snippets();
// Should have 8 default snippets
assert_eq!(defaults.len(), 8);
}
#[test]
fn test_default_snippets_have_unique_ids() {
let defaults = get_default_snippets();
let ids: HashSet<&String> = defaults.iter().map(|s| &s.id).collect();
assert_eq!(ids.len(), defaults.len());
}
#[test]
fn test_default_snippets_ids_start_with_default() {
let defaults = get_default_snippets();
assert!(defaults.iter().all(|s| s.id.starts_with("default-")));
}
#[test]
fn test_snippet_serialization() {
let snippet = create_test_snippet("test-1", "Test Snippet", "Testing", false);
let json = serde_json::to_string(&snippet).expect("Failed to serialize");
let parsed: Snippet = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, snippet.id);
assert_eq!(parsed.name, snippet.name);
assert_eq!(parsed.content, snippet.content);
assert_eq!(parsed.category, snippet.category);
assert_eq!(parsed.is_default, snippet.is_default);
}
#[test]
fn test_snippet_clone() {
let original = create_test_snippet("clone-test", "Clone Test", "Category", true);
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.name, cloned.name);
assert_eq!(original.is_default, cloned.is_default);
}
#[test]
#[allow(clippy::useless_vec)]
fn test_snippet_sorting_by_category_then_name() {
let mut snippets = vec![
create_test_snippet("s1", "Zebra", "B-Category", false),
create_test_snippet("s2", "Apple", "A-Category", false),
create_test_snippet("s3", "Banana", "B-Category", false),
create_test_snippet("s4", "Alpha", "A-Category", false),
];
// Sort by category, then by name (mimics list_snippets behavior)
snippets.sort_by(|a, b| {
let cat_cmp = a.category.cmp(&b.category);
if cat_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
cat_cmp
}
});
// A-Category should come first
assert_eq!(snippets[0].category, "A-Category");
assert_eq!(snippets[1].category, "A-Category");
assert_eq!(snippets[2].category, "B-Category");
assert_eq!(snippets[3].category, "B-Category");
// Within categories, alphabetically by name
assert_eq!(snippets[0].name, "Alpha");
assert_eq!(snippets[1].name, "Apple");
assert_eq!(snippets[2].name, "Banana");
assert_eq!(snippets[3].name, "Zebra");
}
#[test]
fn test_known_default_snippets() {
let defaults = get_default_snippets();
let ids: Vec<&str> = defaults.iter().map(|s| s.id.as_str()).collect();
assert!(ids.contains(&"default-explain-code"));
assert!(ids.contains(&"default-fix-error"));
assert!(ids.contains(&"default-write-tests"));
assert!(ids.contains(&"default-refactor"));
assert!(ids.contains(&"default-optimize"));
assert!(ids.contains(&"default-review-pr"));
assert!(ids.contains(&"default-add-comments"));
assert!(ids.contains(&"default-security-review"));
}
#[test]
fn test_default_snippet_categories() {
let defaults = get_default_snippets();
let categories: HashSet<&String> = defaults.iter().map(|s| &s.category).collect();
assert!(categories.contains(&"Code Review".to_string()));
assert!(categories.contains(&"Debugging".to_string()));
assert!(categories.contains(&"Testing".to_string()));
assert!(categories.contains(&"Performance".to_string()));
assert!(categories.contains(&"Documentation".to_string()));
assert!(categories.contains(&"Security".to_string()));
}
#[test]
fn test_snippet_content_not_empty() {
let defaults = get_default_snippets();
for snippet in defaults {
assert!(
snippet.content.len() > 10,
"Content should be meaningful: {}",
snippet.name
);
}
}
#[test]
fn test_snippet_timestamps() {
let snippet = create_test_snippet("time-test", "Time Test", "Cat", false);
assert!(snippet.created_at <= snippet.updated_at);
}
#[test]
fn test_default_snippets_have_same_timestamps() {
let defaults = get_default_snippets();
// All defaults are created at the same instant
let first_created = defaults[0].created_at;
let first_updated = defaults[0].updated_at;
for snippet in &defaults {
assert_eq!(snippet.created_at, first_created);
assert_eq!(snippet.updated_at, first_updated);
}
}
#[test]
fn test_snippet_retain_non_default() {
let mut snippets = vec![
create_test_snippet("default-1", "Default 1", "Cat", true),
create_test_snippet("custom-1", "Custom 1", "Cat", false),
create_test_snippet("default-2", "Default 2", "Cat", true),
create_test_snippet("custom-2", "Custom 2", "Cat", false),
];
// Mimics reset_default_snippets behavior (retain non-defaults)
snippets.retain(|s| !s.is_default);
assert_eq!(snippets.len(), 2);
assert!(snippets.iter().all(|s| !s.is_default));
}
#[test]
#[allow(clippy::useless_vec)]
fn test_snippet_find_by_id() {
let snippets = vec![
create_test_snippet("snippet-1", "First", "Cat", false),
create_test_snippet("snippet-2", "Second", "Cat", false),
create_test_snippet("snippet-3", "Third", "Cat", false),
];
let found = snippets.iter().find(|s| s.id == "snippet-2");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "Second");
let not_found = snippets.iter().find(|s| s.id == "snippet-999");
assert!(not_found.is_none());
}
#[test]
#[allow(clippy::useless_vec)]
fn test_extract_categories_sorted_and_deduped() {
let snippets = vec![
create_test_snippet("s1", "S1", "Zebra", false),
create_test_snippet("s2", "S2", "Alpha", false),
create_test_snippet("s3", "S3", "Beta", false),
create_test_snippet("s4", "S4", "Alpha", false), // Duplicate
];
let mut categories: Vec<String> = snippets.iter().map(|s| s.category.clone()).collect();
categories.sort();
categories.dedup();
assert_eq!(categories.len(), 3);
assert_eq!(categories[0], "Alpha");
assert_eq!(categories[1], "Beta");
assert_eq!(categories[2], "Zebra");
}
#[test]
fn test_snippet_category_code_review_count() {
let defaults = get_default_snippets();
let code_review_count = defaults
.iter()
.filter(|s| s.category == "Code Review")
.count();
// There should be multiple code review snippets
assert!(code_review_count >= 2);
}
} }
+278
View File
@@ -369,6 +369,36 @@ mod tests {
assert!((cost - 0.165).abs() < 0.0001); 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] #[test]
fn test_usage_stats_accumulation() { fn test_usage_stats_accumulation() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
@@ -381,6 +411,28 @@ mod tests {
assert!((stats.total_cost_usd - 0.033).abs() < 0.0001); 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] #[test]
fn test_session_reset() { fn test_session_reset() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
@@ -394,4 +446,230 @@ mod tests {
assert_eq!(stats.session_cost_usd, 0.0); assert_eq!(stats.session_cost_usd, 0.0);
assert!(stats.total_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"));
}
} }
+287
View File
@@ -137,3 +137,290 @@ pub type SharedTempFileManager = Arc<Mutex<TempFileManager>>;
pub fn create_shared_temp_manager() -> Result<SharedTempFileManager, String> { pub fn create_shared_temp_manager() -> Result<SharedTempFileManager, String> {
Ok(Arc::new(Mutex::new(TempFileManager::new()?))) 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_"));
}
}
+16 -1
View File
@@ -704,7 +704,7 @@ fn process_json_line(
subtype, subtype,
result, result,
permission_denials, permission_denials,
usage: _, usage,
.. ..
} => { } => {
let state = if subtype == "success" { let state = if subtype == "success" {
@@ -713,6 +713,21 @@ fn process_json_line(
CharacterState::Error 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) // Always emit updated stats on result message (less frequent)
// This includes the latest session duration // This includes the latest session duration
let newly_unlocked = { let newly_unlocked = {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "0.3.0", "version": "1.1.1",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+414
View File
@@ -0,0 +1,414 @@
import { describe, it, expect, vi } from "vitest";
import {
slashCommands,
parseSlashCommand,
getMatchingCommands,
isSlashCommand,
type SlashCommand,
} from "./slashCommands";
// Mock all external dependencies
vi.mock("svelte/store", () => ({
get: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("$lib/stores/claude", () => ({
claudeStore: {
addLine: vi.fn(),
clearTerminal: vi.fn(),
activeConversationId: { subscribe: vi.fn() },
currentWorkingDirectory: { subscribe: vi.fn() },
setWorkingDirectory: vi.fn(),
getConversationHistory: vi.fn(),
},
}));
vi.mock("$lib/stores/character", () => ({
characterState: {
setState: vi.fn(),
setTemporaryState: vi.fn(),
},
}));
vi.mock("$lib/tauri", () => ({
setSkipNextGreeting: vi.fn(),
}));
vi.mock("$lib/stores/search", () => ({
searchState: {
setQuery: vi.fn(),
clear: vi.fn(),
},
}));
describe("slashCommands", () => {
describe("slashCommands array", () => {
it("contains expected commands", () => {
const commandNames = slashCommands.map((cmd) => cmd.name);
expect(commandNames).toContain("cd");
expect(commandNames).toContain("clear");
expect(commandNames).toContain("new");
expect(commandNames).toContain("help");
expect(commandNames).toContain("search");
expect(commandNames).toContain("summarise");
expect(commandNames).toContain("skill");
});
it("has 7 commands total", () => {
expect(slashCommands.length).toBe(7);
});
it("each command has required properties", () => {
slashCommands.forEach((cmd) => {
expect(cmd.name).toBeDefined();
expect(typeof cmd.name).toBe("string");
expect(cmd.name.length).toBeGreaterThan(0);
expect(cmd.description).toBeDefined();
expect(typeof cmd.description).toBe("string");
expect(cmd.description.length).toBeGreaterThan(0);
expect(cmd.usage).toBeDefined();
expect(typeof cmd.usage).toBe("string");
expect(cmd.usage.startsWith("/")).toBe(true);
expect(cmd.execute).toBeDefined();
expect(typeof cmd.execute).toBe("function");
});
});
it("cd command has correct metadata", () => {
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd");
expect(cdCmd).toBeDefined();
expect(cdCmd!.description).toBe("Change the working directory");
expect(cdCmd!.usage).toBe("/cd <path>");
});
it("clear command has correct metadata", () => {
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear");
expect(clearCmd).toBeDefined();
expect(clearCmd!.description).toBe("Clear the terminal display (keeps conversation context)");
expect(clearCmd!.usage).toBe("/clear");
});
it("new command has correct metadata", () => {
const newCmd = slashCommands.find((cmd) => cmd.name === "new");
expect(newCmd).toBeDefined();
expect(newCmd!.description).toBe("Start a fresh conversation (resets context)");
expect(newCmd!.usage).toBe("/new");
});
it("help command has correct metadata", () => {
const helpCmd = slashCommands.find((cmd) => cmd.name === "help");
expect(helpCmd).toBeDefined();
expect(helpCmd!.description).toBe("Show available slash commands");
expect(helpCmd!.usage).toBe("/help");
});
it("search command has correct metadata", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search");
expect(searchCmd).toBeDefined();
expect(searchCmd!.description).toBe("Search within the conversation (use /search to clear)");
expect(searchCmd!.usage).toBe("/search [query]");
});
it("summarise command has correct metadata", () => {
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise");
expect(summariseCmd).toBeDefined();
expect(summariseCmd!.description).toBe("Get a summary of the entire conversation");
expect(summariseCmd!.usage).toBe("/summarise");
});
it("skill command has correct metadata", () => {
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill");
expect(skillCmd).toBeDefined();
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
expect(skillCmd!.usage).toBe("/skill [name] [data]");
});
});
describe("parseSlashCommand", () => {
it("returns null for non-slash input", () => {
const result = parseSlashCommand("hello world");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("returns null for empty string", () => {
const result = parseSlashCommand("");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("returns null for whitespace only", () => {
const result = parseSlashCommand(" ");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("parses /cd command without args", () => {
const result = parseSlashCommand("/cd");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("");
});
it("parses /cd command with path argument", () => {
const result = parseSlashCommand("/cd /home/naomi/code");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("/home/naomi/code");
});
it("parses /clear command", () => {
const result = parseSlashCommand("/clear");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("clear");
expect(result.args).toBe("");
});
it("parses /new command", () => {
const result = parseSlashCommand("/new");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("new");
expect(result.args).toBe("");
});
it("parses /help command", () => {
const result = parseSlashCommand("/help");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("help");
expect(result.args).toBe("");
});
it("parses /search command with query", () => {
const result = parseSlashCommand("/search hello world");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("search");
expect(result.args).toBe("hello world");
});
it("parses /search command without query", () => {
const result = parseSlashCommand("/search");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("search");
expect(result.args).toBe("");
});
it("parses /summarise command", () => {
const result = parseSlashCommand("/summarise");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("summarise");
expect(result.args).toBe("");
});
it("parses /skill command with name and data", () => {
const result = parseSlashCommand("/skill onboard-mentee john@example.com");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("skill");
expect(result.args).toBe("onboard-mentee john@example.com");
});
it("parses /skill command with name only", () => {
const result = parseSlashCommand("/skill onboard-mentee");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("skill");
expect(result.args).toBe("onboard-mentee");
});
it("parses /skill command without arguments", () => {
const result = parseSlashCommand("/skill");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("skill");
expect(result.args).toBe("");
});
it("returns null for unknown command", () => {
const result = parseSlashCommand("/unknown");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("is case insensitive for command names", () => {
const result1 = parseSlashCommand("/CD /path");
expect(result1.command).not.toBeNull();
expect(result1.command!.name).toBe("cd");
const result2 = parseSlashCommand("/CLEAR");
expect(result2.command).not.toBeNull();
expect(result2.command!.name).toBe("clear");
const result3 = parseSlashCommand("/Help");
expect(result3.command).not.toBeNull();
expect(result3.command!.name).toBe("help");
});
it("handles leading whitespace", () => {
const result = parseSlashCommand(" /cd /path");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("/path");
});
it("handles trailing whitespace", () => {
const result = parseSlashCommand("/cd /path ");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("/path");
});
it("handles multiple spaces between args", () => {
const result = parseSlashCommand("/search hello world");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("search");
expect(result.args).toBe("hello world");
});
});
describe("getMatchingCommands", () => {
it("returns empty array for non-slash input", () => {
const result = getMatchingCommands("hello");
expect(result).toEqual([]);
});
it("returns empty array for empty string", () => {
const result = getMatchingCommands("");
expect(result).toEqual([]);
});
it("returns all commands for just slash", () => {
const result = getMatchingCommands("/");
expect(result.length).toBe(slashCommands.length);
});
it("returns matching commands for partial input", () => {
const result = getMatchingCommands("/c");
const names = result.map((cmd) => cmd.name);
expect(names).toContain("cd");
expect(names).toContain("clear");
expect(names).not.toContain("help");
});
it("returns single command for exact match", () => {
const result = getMatchingCommands("/cd");
expect(result.length).toBe(1);
expect(result[0].name).toBe("cd");
});
it("returns single command for partial unique match", () => {
const result = getMatchingCommands("/cl");
expect(result.length).toBe(1);
expect(result[0].name).toBe("clear");
});
it("returns matching commands for /s prefix", () => {
const result = getMatchingCommands("/s");
const names = result.map((cmd) => cmd.name);
expect(names).toContain("search");
expect(names).toContain("summarise");
expect(names).toContain("skill");
});
it("is case insensitive", () => {
const result1 = getMatchingCommands("/C");
const result2 = getMatchingCommands("/c");
expect(result1.length).toBe(result2.length);
});
it("returns empty array for no matches", () => {
const result = getMatchingCommands("/xyz");
expect(result).toEqual([]);
});
it("handles whitespace correctly", () => {
const result = getMatchingCommands(" /c");
const names = result.map((cmd) => cmd.name);
expect(names).toContain("cd");
expect(names).toContain("clear");
});
it("returns command for full command name", () => {
const result = getMatchingCommands("/help");
expect(result.length).toBe(1);
expect(result[0].name).toBe("help");
});
it("returns command for /new", () => {
const result = getMatchingCommands("/n");
expect(result.length).toBe(1);
expect(result[0].name).toBe("new");
});
});
describe("isSlashCommand", () => {
it("returns true for input starting with slash", () => {
expect(isSlashCommand("/cd")).toBe(true);
expect(isSlashCommand("/")).toBe(true);
expect(isSlashCommand("/help")).toBe(true);
expect(isSlashCommand("/unknown")).toBe(true);
});
it("returns false for non-slash input", () => {
expect(isSlashCommand("hello")).toBe(false);
expect(isSlashCommand("")).toBe(false);
expect(isSlashCommand("cd")).toBe(false);
});
it("handles whitespace correctly", () => {
expect(isSlashCommand(" /cd")).toBe(true);
expect(isSlashCommand(" hello")).toBe(false);
});
it("returns false for slash in middle of string", () => {
expect(isSlashCommand("hello/world")).toBe(false);
});
});
describe("SlashCommand interface", () => {
it("can create a valid slash command object", () => {
const testCommand: SlashCommand = {
name: "test",
description: "A test command",
usage: "/test [arg]",
execute: vi.fn(),
};
expect(testCommand.name).toBe("test");
expect(testCommand.description).toBe("A test command");
expect(testCommand.usage).toBe("/test [arg]");
expect(typeof testCommand.execute).toBe("function");
});
it("execute can be async function", () => {
const asyncCommand: SlashCommand = {
name: "async",
description: "An async command",
usage: "/async",
execute: async () => {
await Promise.resolve();
},
};
expect(asyncCommand.execute("")).toBeInstanceOf(Promise);
});
it("execute can be sync function", () => {
const syncCommand: SlashCommand = {
name: "sync",
description: "A sync command",
usage: "/sync",
execute: () => {
// Synchronous execution
},
};
const result = syncCommand.execute("");
// Sync function returns undefined, not a Promise
expect(result).toBeUndefined();
});
});
});
+29
View File
@@ -30,6 +30,7 @@
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte"; import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte"; import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte"; import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
import type { Attachment } from "$lib/types/messages"; import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history"; const INPUT_HISTORY_KEY = "hikari-input-history";
@@ -49,6 +50,23 @@
let showClipboardHistory = $state(false); let showClipboardHistory = $state(false);
let streamerModeActive = $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) => { isStreamerMode.subscribe((value) => {
streamerModeActive = value; streamerModeActive = value;
}); });
@@ -876,10 +894,12 @@ User: ${formattedMessage}`;
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div> <div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
<textarea <textarea
bind:this={textareaElement}
bind:value={inputValue} bind:value={inputValue}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
oninput={handleInputChange} oninput={handleInputChange}
onpaste={handlePaste} onpaste={handlePaste}
oncontextmenu={handleContextMenu}
placeholder={isConnected placeholder={isConnected
? "Ask Hikari anything... (type / for commands)" ? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."} : "Connect to Claude first..."}
@@ -958,6 +978,15 @@ User: ${formattedMessage}`;
/> />
{/if} {/if}
{#if contextMenuShow && textareaElement}
<TextInputContextMenu
x={contextMenuX}
y={contextMenuY}
inputElement={textareaElement}
onClose={closeContextMenu}
/>
{/if}
<style> <style>
.input-bar { .input-bar {
display: flex; display: flex;
@@ -12,6 +12,7 @@
{ keys: ["Escape"], description: "Close modals and panels" }, { keys: ["Escape"], description: "Close modals and panels" },
{ keys: ["Ctrl", "L"], description: "Clear the terminal" }, { keys: ["Ctrl", "L"], description: "Clear the terminal" },
{ keys: ["Ctrl", ","], description: "Open settings" }, { keys: ["Ctrl", ","], description: "Open settings" },
{ keys: ["Ctrl", "E"], description: "Toggle file editor" },
{ keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" }, { keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" },
{ keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" }, { keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" },
], ],
@@ -26,6 +27,17 @@
{ keys: ["↓"], description: "Next input from history" }, { 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", category: "Slash Commands",
items: [ items: [
+29
View File
@@ -13,6 +13,7 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config"; import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
import { editorStore } from "$lib/stores/editor";
import type { ConnectionStatus } from "$lib/types/messages"; import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte"; import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte"; import StatsDisplay from "./StatsDisplay.svelte";
@@ -80,6 +81,15 @@
streamerModeActive = value; streamerModeActive = value;
}); });
let editorVisible = $state(false);
editorStore.isEditorVisible.subscribe((value) => {
editorVisible = value;
});
function toggleEditor() {
editorStore.toggleEditor();
}
onMount(async () => { onMount(async () => {
appVersion = await getVersion(); appVersion = await getVersion();
}); });
@@ -307,6 +317,25 @@
/> />
</svg> </svg>
</button> </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 <button
onclick={() => (showStats = !showStats)} onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}" class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
+93 -6
View File
@@ -155,6 +155,30 @@
terminalElement.removeEventListener("copy", handleCopy); 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> </script>
<div <div
@@ -185,7 +209,7 @@
</div> </div>
{:else} {:else}
{#each lines as line (line.id)} {#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)}"> <div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span> <span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
{#if getLinePrefix(line.type)} {#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span> <span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
@@ -193,11 +217,33 @@
{#if line.toolName} {#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span> <span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if} {/if}
{#if line.type === "assistant"} {#if line.type === "assistant" || line.type === "user"}
<Markdown <div class="message-content-wrapper">
content={maskPaths(line.content, hidePaths)} <Markdown
searchQuery={currentSearchQuery} 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>
{:else} {:else}
<HighlightedText <HighlightedText
content={maskPaths(line.content, hidePaths)} content={maskPaths(line.content, hidePaths)}
@@ -267,4 +313,45 @@
border-radius: 2px; border-radius: 2px;
padding: 0 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> </style>
@@ -0,0 +1,228 @@
<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>
+482
View File
@@ -0,0 +1,482 @@
<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>
@@ -0,0 +1,131 @@
<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>
@@ -0,0 +1,238 @@
<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>
@@ -0,0 +1,253 @@
<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>
+170
View File
@@ -0,0 +1,170 @@
<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>
@@ -0,0 +1,358 @@
<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>
@@ -0,0 +1,203 @@
<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>
@@ -0,0 +1,208 @@
<script lang="ts">
import type { FileEntry } from "$lib/types/editor";
import { editorStore } from "$lib/stores/editor";
import Self from "./FileTreeItem.svelte";
interface Props {
entry: FileEntry;
depth?: number;
onContextMenu?: (event: MouseEvent, entry: FileEntry) => void;
}
let { entry, depth = 0, onContextMenu }: Props = $props();
function handleClick() {
if (entry.isDirectory) {
editorStore.toggleDirectory(entry);
} else {
editorStore.openFile(entry.path);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
}
}
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
onContextMenu?.(event, entry);
}
const isExpanded = $derived(entry.isExpanded ?? false);
const isLoading = $derived(entry.isLoading ?? false);
const children = $derived(entry.children ?? []);
</script>
<div class="file-tree-item">
<button
class="item-row"
class:directory={entry.isDirectory}
class:file={!entry.isDirectory}
style="padding-left: {depth * 16 + 8}px"
onclick={handleClick}
onkeydown={handleKeydown}
oncontextmenu={handleContextMenu}
title={entry.path}
>
{#if entry.isDirectory}
<span class="icon">
{#if isLoading}
<svg
class="spinner"
width="14"
height="14"
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>
{:else if isExpanded}
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
{:else}
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 6l6 6-6 6" />
</svg>
{/if}
</span>
<span class="folder-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path
d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"
/>
</svg>
</span>
{:else}
<span class="icon spacer"></span>
<span class="file-icon">
<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" />
<polyline points="14 2 14 8 20 8" />
</svg>
</span>
{/if}
<span class="name">{entry.name}</span>
</button>
{#if entry.isDirectory && isExpanded && children.length > 0}
<div class="children">
{#each children as child (child.path)}
<Self entry={child} depth={depth + 1} {onContextMenu} />
{/each}
</div>
{/if}
</div>
<style>
.file-tree-item {
user-select: none;
}
.item-row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 4px 8px;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
border-radius: 4px;
transition: background-color 0.15s ease;
}
.item-row:hover {
background-color: var(--bg-secondary);
}
.item-row:focus {
outline: 1px solid var(--accent-primary);
outline-offset: -1px;
}
.icon {
flex-shrink: 0;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.icon.spacer {
visibility: hidden;
}
.folder-icon {
flex-shrink: 0;
color: var(--accent-primary);
}
.file-icon {
flex-shrink: 0;
color: var(--text-secondary);
}
.name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.children {
margin-left: 0;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,173 @@
<script lang="ts">
interface Props {
title: string;
placeholder?: string;
confirmText?: string;
cancelText?: string;
initialValue?: string;
onConfirm: (value: string) => void;
onCancel: () => void;
}
let {
title,
placeholder = "",
confirmText = "Create",
cancelText = "Cancel",
initialValue = "",
onConfirm,
onCancel,
}: Props = $props();
let inputValue = $state(initialValue);
let inputElement: HTMLInputElement | undefined = $state();
$effect(() => {
if (inputElement) {
inputElement.focus();
// Select all text for rename operations
if (initialValue) {
inputElement.select();
}
}
});
function handleSubmit() {
const trimmed = inputValue.trim();
if (trimmed) {
onConfirm(trimmed);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onCancel();
} else if (event.key === "Enter") {
handleSubmit();
}
}
</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>
<input
bind:this={inputElement}
bind:value={inputValue}
type="text"
{placeholder}
class="dialog-input"
/>
<div class="dialog-actions">
<button class="btn-cancel" onclick={onCancel}>
{cancelText}
</button>
<button class="btn-confirm" onclick={handleSubmit} disabled={!inputValue.trim()}>
{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: 1rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.dialog-input {
width: 100%;
margin-bottom: 1.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
font-size: 0.875rem;
color: var(--text-primary);
outline: none;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.dialog-input::placeholder {
color: var(--text-secondary);
}
.dialog-input:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 1px var(--accent-primary);
}
.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:disabled {
cursor: not-allowed;
opacity: 0.5;
}
</style>
+329
View File
@@ -0,0 +1,329 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NotificationType, NOTIFICATION_SOUNDS, type NotificationSound } from "./types";
// Mock HTMLAudioElement for soundPlayer tests
class MockAudioElement {
src: string = "";
preload: string = "";
volume: number = 1;
constructor(src?: string) {
if (src) this.src = src;
}
cloneNode(): MockAudioElement {
const clone = new MockAudioElement(this.src);
clone.volume = this.volume;
return clone;
}
async play(): Promise<void> {
return Promise.resolve();
}
}
// Store original Audio before mocking
const OriginalAudio = globalThis.Audio;
describe("notifications", () => {
describe("NotificationType enum", () => {
it("has SUCCESS type", () => {
expect(NotificationType.SUCCESS).toBe("success");
});
it("has ERROR type", () => {
expect(NotificationType.ERROR).toBe("error");
});
it("has PERMISSION type", () => {
expect(NotificationType.PERMISSION).toBe("permission");
});
it("has CONNECTION type", () => {
expect(NotificationType.CONNECTION).toBe("connection");
});
it("has TASK_START type", () => {
expect(NotificationType.TASK_START).toBe("task_start");
});
it("has ACHIEVEMENT type", () => {
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
});
it("has exactly 6 notification types", () => {
const types = Object.values(NotificationType);
expect(types.length).toBe(6);
});
});
describe("NOTIFICATION_SOUNDS constant", () => {
it("has sounds for all notification types", () => {
Object.values(NotificationType).forEach((type) => {
expect(NOTIFICATION_SOUNDS[type]).toBeDefined();
});
});
it("each sound has required properties", () => {
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
expect(sound.type).toBeDefined();
expect(sound.filename).toBeDefined();
expect(sound.phrase).toBeDefined();
expect(typeof sound.filename).toBe("string");
expect(typeof sound.phrase).toBe("string");
expect(sound.filename.endsWith(".mp3")).toBe(true);
});
});
it("SUCCESS sound has correct properties", () => {
const sound = NOTIFICATION_SOUNDS[NotificationType.SUCCESS];
expect(sound.type).toBe(NotificationType.SUCCESS);
expect(sound.filename).toBe("im-done.mp3");
expect(sound.phrase).toBe("I'm done!");
expect(sound.volume).toBe(0.7);
});
it("ERROR sound has correct properties", () => {
const sound = NOTIFICATION_SOUNDS[NotificationType.ERROR];
expect(sound.type).toBe(NotificationType.ERROR);
expect(sound.filename).toBe("oh-no.mp3");
expect(sound.phrase).toBe("Oh no...");
expect(sound.volume).toBe(0.8);
});
it("PERMISSION sound has correct properties", () => {
const sound = NOTIFICATION_SOUNDS[NotificationType.PERMISSION];
expect(sound.type).toBe(NotificationType.PERMISSION);
expect(sound.filename).toBe("access-please.mp3");
expect(sound.phrase).toBe("Access please!");
expect(sound.volume).toBe(0.9);
});
it("CONNECTION sound has correct properties", () => {
const sound = NOTIFICATION_SOUNDS[NotificationType.CONNECTION];
expect(sound.type).toBe(NotificationType.CONNECTION);
expect(sound.filename).toBe("connected.mp3");
expect(sound.phrase).toBe("Connected!");
expect(sound.volume).toBe(0.7);
});
it("TASK_START sound has correct properties", () => {
const sound = NOTIFICATION_SOUNDS[NotificationType.TASK_START];
expect(sound.type).toBe(NotificationType.TASK_START);
expect(sound.filename).toBe("working-on-it.mp3");
expect(sound.phrase).toBe("Working on it!");
expect(sound.volume).toBe(0.6);
});
it("ACHIEVEMENT sound has correct properties", () => {
const sound = NOTIFICATION_SOUNDS[NotificationType.ACHIEVEMENT];
expect(sound.type).toBe(NotificationType.ACHIEVEMENT);
expect(sound.filename).toBe("achievement.mp3");
expect(sound.phrase).toBe("Achievement Get~!");
expect(sound.volume).toBe(0.8);
});
it("all volumes are within valid range (0-1)", () => {
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
if (sound.volume !== undefined) {
expect(sound.volume).toBeGreaterThanOrEqual(0);
expect(sound.volume).toBeLessThanOrEqual(1);
}
});
});
});
describe("NotificationSound interface", () => {
it("can create a valid notification sound object", () => {
const sound: NotificationSound = {
type: NotificationType.SUCCESS,
filename: "test-sound.mp3",
phrase: "Test phrase",
volume: 0.5,
};
expect(sound.type).toBe(NotificationType.SUCCESS);
expect(sound.filename).toBe("test-sound.mp3");
expect(sound.phrase).toBe("Test phrase");
expect(sound.volume).toBe(0.5);
});
it("volume is optional", () => {
const sound: NotificationSound = {
type: NotificationType.ERROR,
filename: "error.mp3",
phrase: "Error occurred",
};
expect(sound.volume).toBeUndefined();
});
});
describe("SoundPlayer class", () => {
beforeEach(() => {
// Mock Audio constructor
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
});
afterEach(() => {
// Restore original Audio
globalThis.Audio = OriginalAudio;
vi.resetModules();
});
it("can import soundPlayer singleton", async () => {
const { soundPlayer } = await import("./soundPlayer");
expect(soundPlayer).toBeDefined();
});
it("setEnabled changes enabled state", async () => {
const { soundPlayer } = await import("./soundPlayer");
soundPlayer.setEnabled(true);
expect(soundPlayer.isEnabled()).toBe(true);
soundPlayer.setEnabled(false);
expect(soundPlayer.isEnabled()).toBe(false);
});
it("starts disabled by default", async () => {
// Need to reimport to get fresh instance behavior
// But since it's a singleton, we just test the method
const { soundPlayer } = await import("./soundPlayer");
// Reset to default state
soundPlayer.setEnabled(false);
expect(soundPlayer.isEnabled()).toBe(false);
});
it("setGlobalVolume clamps values to 0-1 range", async () => {
const { soundPlayer } = await import("./soundPlayer");
// Test that it doesn't throw on edge cases
soundPlayer.setGlobalVolume(0);
soundPlayer.setGlobalVolume(1);
soundPlayer.setGlobalVolume(0.5);
// Test clamping below 0
soundPlayer.setGlobalVolume(-0.5);
// Test clamping above 1
soundPlayer.setGlobalVolume(1.5);
});
it("play returns early when disabled", async () => {
const { soundPlayer } = await import("./soundPlayer");
soundPlayer.setEnabled(false);
// Should not throw when disabled
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
});
it("play attempts to play when enabled", async () => {
const { soundPlayer } = await import("./soundPlayer");
soundPlayer.setEnabled(true);
// Should not throw
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
});
});
describe("NotificationManager class", () => {
beforeEach(() => {
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
vi.resetModules();
});
afterEach(() => {
globalThis.Audio = OriginalAudio;
});
it("can import notificationManager singleton", async () => {
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
}));
const { notificationManager } = await import("./notificationManager");
expect(notificationManager).toBeDefined();
});
it("has notifySuccess method", async () => {
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
}));
const { notificationManager } = await import("./notificationManager");
expect(typeof notificationManager.notifySuccess).toBe("function");
});
it("has notifyError method", async () => {
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
}));
const { notificationManager } = await import("./notificationManager");
expect(typeof notificationManager.notifyError).toBe("function");
});
it("has notifyPermission method", async () => {
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
}));
const { notificationManager } = await import("./notificationManager");
expect(typeof notificationManager.notifyPermission).toBe("function");
});
it("has notifyConnection method", async () => {
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
}));
const { notificationManager } = await import("./notificationManager");
expect(typeof notificationManager.notifyConnection).toBe("function");
});
it("has notifyTaskStart method", async () => {
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
}));
const { notificationManager } = await import("./notificationManager");
expect(typeof notificationManager.notifyTaskStart).toBe("function");
});
it("has notify method", async () => {
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn().mockRejectedValue(new Error("Not available")),
}));
const { notificationManager } = await import("./notificationManager");
expect(typeof notificationManager.notify).toBe("function");
});
});
describe("notification sounds file paths", () => {
it("all sound files have valid paths", () => {
Object.values(NOTIFICATION_SOUNDS).forEach((sound) => {
// Check that filename doesn't contain path traversal
expect(sound.filename).not.toContain("..");
expect(sound.filename).not.toContain("/");
expect(sound.filename).not.toContain("\\");
});
});
it("sound filenames are unique", () => {
const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
const uniqueFilenames = new Set(filenames);
expect(uniqueFilenames.size).toBe(filenames.length);
});
it("phrases are unique", () => {
const phrases = Object.values(NOTIFICATION_SOUNDS).map((s) => s.phrase);
const uniquePhrases = new Set(phrases);
expect(uniquePhrases.size).toBe(phrases.length);
});
});
});
+480
View File
@@ -0,0 +1,480 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
configStore,
maskPaths,
clampFontSize,
applyFontSize,
applyTheme,
applyCustomThemeColors,
clearCustomThemeColors,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
type HikariConfig,
type Theme,
type CustomThemeColors,
} from "./config";
// Mock Tauri APIs
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
describe("config store", () => {
describe("font size constants", () => {
it("has correct MIN_FONT_SIZE", () => {
expect(MIN_FONT_SIZE).toBe(10);
});
it("has correct MAX_FONT_SIZE", () => {
expect(MAX_FONT_SIZE).toBe(24);
});
it("has correct DEFAULT_FONT_SIZE", () => {
expect(DEFAULT_FONT_SIZE).toBe(14);
});
});
describe("clampFontSize", () => {
it("returns the same value when within range", () => {
expect(clampFontSize(14)).toBe(14);
expect(clampFontSize(10)).toBe(10);
expect(clampFontSize(24)).toBe(24);
expect(clampFontSize(18)).toBe(18);
});
it("clamps values below minimum", () => {
expect(clampFontSize(5)).toBe(MIN_FONT_SIZE);
expect(clampFontSize(0)).toBe(MIN_FONT_SIZE);
expect(clampFontSize(-10)).toBe(MIN_FONT_SIZE);
expect(clampFontSize(9)).toBe(MIN_FONT_SIZE);
});
it("clamps values above maximum", () => {
expect(clampFontSize(30)).toBe(MAX_FONT_SIZE);
expect(clampFontSize(100)).toBe(MAX_FONT_SIZE);
expect(clampFontSize(25)).toBe(MAX_FONT_SIZE);
});
});
describe("maskPaths", () => {
it("returns text unchanged when hidePaths is false", () => {
const text = "/home/naomi/code/project/file.ts";
expect(maskPaths(text, false)).toBe(text);
});
it("masks Unix home paths", () => {
const text = "/home/naomi/code/project/file.ts";
expect(maskPaths(text, true)).toBe("/home/****/code/project/file.ts");
});
it("masks macOS user paths", () => {
const text = "/Users/naomi/Documents/project/file.ts";
expect(maskPaths(text, true)).toBe("/Users/****/Documents/project/file.ts");
});
it("masks Windows user paths", () => {
const text = "C:\\Users\\naomi\\Documents\\project\\file.ts";
expect(maskPaths(text, true)).toBe("C:\\Users\\****\\Documents\\project\\file.ts");
});
it("masks tilde paths", () => {
const text = "~/code/project/file.ts";
expect(maskPaths(text, true)).toBe("****/code/project/file.ts");
});
it("masks multiple paths in the same text", () => {
const text = "Editing /home/naomi/file1.ts and /home/naomi/file2.ts";
expect(maskPaths(text, true)).toBe("Editing /home/****/file1.ts and /home/****/file2.ts");
});
it("handles mixed path types", () => {
const text = "Unix: /home/user/file, Mac: /Users/user/file, Win: C:\\Users\\user\\file";
const expected = "Unix: /home/****/file, Mac: /Users/****/file, Win: C:\\Users\\****\\file";
expect(maskPaths(text, true)).toBe(expected);
});
it("handles paths with special characters in username", () => {
const text = "/home/user-name_123/project";
expect(maskPaths(text, true)).toBe("/home/****/project");
});
it("does not mask non-path text", () => {
const text = "This is just regular text without any paths";
expect(maskPaths(text, true)).toBe(text);
});
it("handles empty string", () => {
expect(maskPaths("", true)).toBe("");
expect(maskPaths("", false)).toBe("");
});
});
describe("Theme type", () => {
it("accepts valid theme values", () => {
const themes: Theme[] = ["dark", "light", "high-contrast", "custom"];
themes.forEach((theme) => {
expect(["dark", "light", "high-contrast", "custom"]).toContain(theme);
});
});
});
describe("CustomThemeColors interface", () => {
it("can create a valid custom theme colors object", () => {
const colors: CustomThemeColors = {
bg_primary: "#1a1a2e",
bg_secondary: "#16213e",
bg_terminal: "#0f0f23",
accent_primary: "#e94560",
accent_secondary: "#533483",
text_primary: "#eaeaea",
text_secondary: "#a0a0a0",
border_color: "#333355",
};
expect(colors.bg_primary).toBe("#1a1a2e");
expect(colors.accent_primary).toBe("#e94560");
});
it("allows null values for optional colors", () => {
const colors: CustomThemeColors = {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: "#e94560",
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
};
expect(colors.bg_primary).toBeNull();
expect(colors.accent_primary).toBe("#e94560");
});
});
describe("HikariConfig interface", () => {
it("can create a valid config object with all fields", () => {
const config: HikariConfig = {
model: "claude-sonnet-4",
api_key: "test-key",
custom_instructions: "Be helpful",
mcp_servers_json: "{}",
auto_granted_tools: ["Read", "Write"],
theme: "dark",
greeting_enabled: true,
greeting_custom_prompt: "Hello!",
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: true,
update_checks_enabled: true,
character_panel_width: 300,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: "Naomi",
profile_avatar_path: "/path/to/avatar.png",
profile_bio: "Developer",
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,
},
};
expect(config.model).toBe("claude-sonnet-4");
expect(config.auto_granted_tools).toEqual(["Read", "Write"]);
expect(config.theme).toBe("dark");
});
it("allows null values for optional fields", () => {
const config: HikariConfig = {
model: null,
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
auto_granted_tools: [],
theme: "dark",
greeting_enabled: true,
greeting_custom_prompt: null,
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
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,
},
};
expect(config.model).toBeNull();
expect(config.api_key).toBeNull();
expect(config.character_panel_width).toBeNull();
});
});
describe("applyFontSize", () => {
beforeEach(() => {
// Reset document state
if (typeof document !== "undefined") {
document.documentElement.style.removeProperty("--terminal-font-size");
}
});
it("sets CSS variable for valid font size", () => {
applyFontSize(16);
const value = document.documentElement.style.getPropertyValue("--terminal-font-size");
expect(value).toBe("16px");
});
it("clamps font size below minimum", () => {
applyFontSize(5);
const value = document.documentElement.style.getPropertyValue("--terminal-font-size");
expect(value).toBe(`${MIN_FONT_SIZE}px`);
});
it("clamps font size above maximum", () => {
applyFontSize(50);
const value = document.documentElement.style.getPropertyValue("--terminal-font-size");
expect(value).toBe(`${MAX_FONT_SIZE}px`);
});
});
describe("applyTheme", () => {
beforeEach(() => {
// Reset document state
if (typeof document !== "undefined") {
document.documentElement.removeAttribute("data-theme");
clearCustomThemeColors();
}
});
it("sets data-theme attribute for dark theme", () => {
applyTheme("dark");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("sets data-theme attribute for light theme", () => {
applyTheme("light");
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
it("sets data-theme attribute for high-contrast theme", () => {
applyTheme("high-contrast");
expect(document.documentElement.getAttribute("data-theme")).toBe("high-contrast");
});
it("uses dark as base for custom theme", () => {
applyTheme("custom");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("applies custom colors when theme is custom", () => {
const colors: CustomThemeColors = {
bg_primary: "#1a1a2e",
bg_secondary: null,
bg_terminal: null,
accent_primary: "#e94560",
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
};
applyTheme("custom", colors);
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#1a1a2e");
expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("#e94560");
});
it("does not apply custom colors for non-custom themes", () => {
const colors: CustomThemeColors = {
bg_primary: "#1a1a2e",
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
};
applyTheme("dark", colors);
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("");
});
});
describe("applyCustomThemeColors", () => {
beforeEach(() => {
clearCustomThemeColors();
});
it("applies all provided colors", () => {
const colors: CustomThemeColors = {
bg_primary: "#111111",
bg_secondary: "#222222",
bg_terminal: "#333333",
accent_primary: "#444444",
accent_secondary: "#555555",
text_primary: "#666666",
text_secondary: "#777777",
border_color: "#888888",
};
applyCustomThemeColors(colors);
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#111111");
expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("#222222");
expect(document.documentElement.style.getPropertyValue("--bg-terminal")).toBe("#333333");
expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("#444444");
expect(document.documentElement.style.getPropertyValue("--accent-secondary")).toBe("#555555");
expect(document.documentElement.style.getPropertyValue("--text-primary")).toBe("#666666");
expect(document.documentElement.style.getPropertyValue("--text-secondary")).toBe("#777777");
expect(document.documentElement.style.getPropertyValue("--border-color")).toBe("#888888");
});
it("skips null values", () => {
const colors: CustomThemeColors = {
bg_primary: "#111111",
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
};
applyCustomThemeColors(colors);
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#111111");
expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("");
});
});
describe("clearCustomThemeColors", () => {
it("removes all custom theme CSS properties", () => {
// First apply some colors
const colors: CustomThemeColors = {
bg_primary: "#111111",
bg_secondary: "#222222",
bg_terminal: "#333333",
accent_primary: "#444444",
accent_secondary: "#555555",
text_primary: "#666666",
text_secondary: "#777777",
border_color: "#888888",
};
applyCustomThemeColors(colors);
// Then clear them
clearCustomThemeColors();
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("");
expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("");
expect(document.documentElement.style.getPropertyValue("--bg-terminal")).toBe("");
expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("");
expect(document.documentElement.style.getPropertyValue("--accent-secondary")).toBe("");
expect(document.documentElement.style.getPropertyValue("--text-primary")).toBe("");
expect(document.documentElement.style.getPropertyValue("--text-secondary")).toBe("");
expect(document.documentElement.style.getPropertyValue("--border-color")).toBe("");
});
});
describe("derived stores", () => {
// Note: These tests verify the derived store logic by testing the derivation functions
// The actual stores depend on configStore which requires Tauri invoke mocking
it("isDarkTheme returns true for dark theme config", () => {
// Test the derivation logic
const darkConfig = { theme: "dark" as Theme };
expect(darkConfig.theme === "dark").toBe(true);
});
it("isDarkTheme returns false for light theme config", () => {
const lightConfig = { theme: "light" as Theme };
expect(lightConfig.theme === "dark").toBe(false);
});
it("isStreamerMode derives from streamer_mode config", () => {
const configWithStreamerMode = { streamer_mode: true };
const configWithoutStreamerMode = { streamer_mode: false };
expect(configWithStreamerMode.streamer_mode).toBe(true);
expect(configWithoutStreamerMode.streamer_mode).toBe(false);
});
it("isCompactMode derives from compact_mode config", () => {
const configWithCompactMode = { compact_mode: true };
const configWithoutCompactMode = { compact_mode: false };
expect(configWithCompactMode.compact_mode).toBe(true);
expect(configWithoutCompactMode.compact_mode).toBe(false);
});
it("shouldHidePaths requires both streamer_mode and streamer_hide_paths", () => {
const config1 = { streamer_mode: true, streamer_hide_paths: true };
const config2 = { streamer_mode: true, streamer_hide_paths: false };
const config3 = { streamer_mode: false, streamer_hide_paths: true };
const config4 = { streamer_mode: false, streamer_hide_paths: false };
expect(config1.streamer_mode && config1.streamer_hide_paths).toBe(true);
expect(config2.streamer_mode && config2.streamer_hide_paths).toBe(false);
expect(config3.streamer_mode && config3.streamer_hide_paths).toBe(false);
expect(config4.streamer_mode && config4.streamer_hide_paths).toBe(false);
});
});
describe("configStore methods", () => {
it("has all expected methods", () => {
expect(typeof configStore.loadConfig).toBe("function");
expect(typeof configStore.saveConfig).toBe("function");
expect(typeof configStore.updateConfig).toBe("function");
expect(typeof configStore.openSidebar).toBe("function");
expect(typeof configStore.closeSidebar).toBe("function");
expect(typeof configStore.toggleSidebar).toBe("function");
expect(typeof configStore.setTheme).toBe("function");
expect(typeof configStore.setCustomThemeColors).toBe("function");
expect(typeof configStore.setFontSize).toBe("function");
expect(typeof configStore.increaseFontSize).toBe("function");
expect(typeof configStore.decreaseFontSize).toBe("function");
expect(typeof configStore.resetFontSize).toBe("function");
expect(typeof configStore.addAutoGrantedTool).toBe("function");
expect(typeof configStore.removeAutoGrantedTool).toBe("function");
expect(typeof configStore.getConfig).toBe("function");
expect(typeof configStore.toggleStreamerMode).toBe("function");
expect(typeof configStore.toggleCompactMode).toBe("function");
expect(typeof configStore.setCompactMode).toBe("function");
});
it("has subscribable stores", () => {
expect(typeof configStore.config.subscribe).toBe("function");
expect(typeof configStore.isLoading.subscribe).toBe("function");
expect(typeof configStore.isSidebarOpen.subscribe).toBe("function");
expect(typeof configStore.saveError.subscribe).toBe("function");
});
});
});
+525
View File
@@ -0,0 +1,525 @@
import { describe, it, expect } from "vitest";
// Test the Conversation interface and store behavior
describe("Conversation interface", () => {
it("defines all required fields", () => {
const conversation = {
id: "conv-123",
name: "Test Conversation",
terminalLines: [],
sessionId: null,
connectionStatus: "disconnected" as const,
workingDirectory: "",
characterState: "idle" as const,
isProcessing: false,
grantedTools: new Set<string>(),
pendingPermission: null,
pendingQuestion: null,
scrollPosition: -1,
createdAt: new Date(),
lastActivityAt: new Date(),
attachments: [],
};
expect(conversation.id).toBe("conv-123");
expect(conversation.name).toBe("Test Conversation");
expect(conversation.terminalLines).toEqual([]);
expect(conversation.sessionId).toBeNull();
expect(conversation.connectionStatus).toBe("disconnected");
expect(conversation.workingDirectory).toBe("");
expect(conversation.characterState).toBe("idle");
expect(conversation.isProcessing).toBe(false);
expect(conversation.grantedTools.size).toBe(0);
expect(conversation.pendingPermission).toBeNull();
expect(conversation.pendingQuestion).toBeNull();
expect(conversation.scrollPosition).toBe(-1);
expect(conversation.attachments).toEqual([]);
});
it("handles terminal lines array", () => {
const lines = [
{
id: "line-1",
type: "user" as const,
content: "Hello",
timestamp: new Date(),
},
{
id: "line-2",
type: "assistant" as const,
content: "Hi there!",
timestamp: new Date(),
},
];
expect(lines).toHaveLength(2);
expect(lines[0].type).toBe("user");
expect(lines[1].type).toBe("assistant");
});
});
describe("conversation ID generation", () => {
it("generates unique IDs with timestamp prefix", () => {
let counter = 0;
const generateId = () => `conv-${Date.now()}-${counter++}`;
const id1 = generateId();
const id2 = generateId();
expect(id1).toMatch(/^conv-\d+-\d+$/);
expect(id2).toMatch(/^conv-\d+-\d+$/);
expect(id1).not.toBe(id2);
});
});
describe("line ID generation", () => {
it("generates unique line IDs", () => {
let counter = 0;
const generateLineId = () => `line-${Date.now()}-${counter++}`;
const id1 = generateLineId();
const id2 = generateLineId();
expect(id1).toMatch(/^line-\d+-\d+$/);
expect(id2).toMatch(/^line-\d+-\d+$/);
expect(id1).not.toBe(id2);
});
});
describe("connection status types", () => {
it("supports all connection status values", () => {
const statuses = ["connected", "disconnected", "connecting", "error"] as const;
statuses.forEach((status) => {
expect(typeof status).toBe("string");
});
});
});
describe("character state types", () => {
it("supports all character state values", () => {
const states = [
"idle",
"thinking",
"typing",
"searching",
"coding",
"mcp",
"permission",
"success",
"error",
] as const;
states.forEach((state) => {
expect(typeof state).toBe("string");
});
});
});
describe("terminal line types", () => {
it("supports all terminal line types", () => {
const types = ["user", "assistant", "system", "tool", "error"] as const;
types.forEach((type) => {
expect(typeof type).toBe("string");
});
});
it("creates terminal line with required fields", () => {
const line = {
id: "line-123",
type: "user" as const,
content: "Test message",
timestamp: new Date(),
toolName: undefined,
};
expect(line.id).toBe("line-123");
expect(line.type).toBe("user");
expect(line.content).toBe("Test message");
expect(line.timestamp).toBeInstanceOf(Date);
expect(line.toolName).toBeUndefined();
});
it("creates terminal line with tool name", () => {
const line = {
id: "line-456",
type: "tool" as const,
content: "Tool output",
timestamp: new Date(),
toolName: "Read",
};
expect(line.toolName).toBe("Read");
});
});
describe("permission request structure", () => {
it("creates valid permission request", () => {
const request = {
id: "perm-123",
tool: "Bash",
description: "Run shell command",
input: '{"command": "ls"}',
};
expect(request.id).toBe("perm-123");
expect(request.tool).toBe("Bash");
expect(request.description).toBe("Run shell command");
expect(request.input).toContain("command");
});
});
describe("user question structure", () => {
it("creates valid question event", () => {
const question = {
id: "q-123",
question: "Which option?",
options: [
{ label: "A", description: "Option A" },
{ label: "B", description: "Option B" },
],
conversation_id: "conv-123",
};
expect(question.id).toBe("q-123");
expect(question.question).toBe("Which option?");
expect(question.options).toHaveLength(2);
});
});
describe("attachment structure", () => {
it("creates valid file attachment", () => {
const attachment = {
id: "att-123",
type: "file" as const,
name: "test.txt",
path: "/tmp/test.txt",
size: 1024,
mimeType: "text/plain",
};
expect(attachment.id).toBe("att-123");
expect(attachment.type).toBe("file");
expect(attachment.name).toBe("test.txt");
});
it("creates valid image attachment", () => {
const attachment = {
id: "att-456",
type: "image" as const,
name: "screenshot.png",
path: "/tmp/screenshot.png",
size: 50000,
mimeType: "image/png",
previewUrl: "data:image/png;base64,...",
};
expect(attachment.type).toBe("image");
expect(attachment.previewUrl).toContain("data:image");
});
});
describe("conversation management operations", () => {
it("creates new conversation with default values", () => {
let counter = 1;
const createNewConversation = (name?: string) => {
const id = `conv-${Date.now()}-${counter++}`;
return {
id,
name: name || `Conversation ${counter}`,
terminalLines: [],
sessionId: null,
connectionStatus: "disconnected" as const,
workingDirectory: "",
characterState: "idle" as const,
isProcessing: false,
grantedTools: new Set<string>(),
pendingPermission: null,
pendingQuestion: null,
scrollPosition: -1,
createdAt: new Date(),
lastActivityAt: new Date(),
attachments: [],
};
};
const conv = createNewConversation("My Chat");
expect(conv.name).toBe("My Chat");
expect(conv.connectionStatus).toBe("disconnected");
expect(conv.characterState).toBe("idle");
});
it("uses default name when not provided", () => {
const counter = 5;
const createNewConversation = (name?: string) => ({
name: name || `Conversation ${counter}`,
});
const conv = createNewConversation();
expect(conv.name).toBe("Conversation 5");
});
});
describe("conversation history formatting", () => {
it("formats user and assistant messages for history", () => {
const lines = [
{ type: "user" as const, content: "Hello" },
{ type: "assistant" as const, content: "Hi there!" },
{ type: "system" as const, content: "Connected" },
{ type: "user" as const, content: "How are you?" },
];
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
const history = relevantLines
.map((line) => {
const role = line.type === "user" ? "User" : "Assistant";
return `${role}: ${line.content}`;
})
.join("\n\n");
expect(history).toContain("User: Hello");
expect(history).toContain("Assistant: Hi there!");
expect(history).toContain("User: How are you?");
expect(history).not.toContain("Connected");
});
it("returns empty string for no messages", () => {
const lines: Array<{ type: string; content: string }> = [];
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
expect(relevantLines.length).toBe(0);
const history = relevantLines.length === 0 ? "" : "has content";
expect(history).toBe("");
});
});
describe("tool granting", () => {
it("tracks granted tools with Set", () => {
const grantedTools = new Set<string>();
grantedTools.add("Read");
grantedTools.add("Write");
grantedTools.add("Bash");
expect(grantedTools.has("Read")).toBe(true);
expect(grantedTools.has("Write")).toBe(true);
expect(grantedTools.has("Bash")).toBe(true);
expect(grantedTools.has("Edit")).toBe(false);
expect(grantedTools.size).toBe(3);
});
it("clears all granted tools", () => {
const grantedTools = new Set<string>(["Read", "Write"]);
expect(grantedTools.size).toBe(2);
grantedTools.clear();
expect(grantedTools.size).toBe(0);
});
});
describe("scroll position handling", () => {
it("uses -1 for auto-scroll (scroll to bottom)", () => {
const scrollPosition = -1;
const isAutoScroll = scrollPosition === -1;
expect(isAutoScroll).toBe(true);
});
it("uses positive values for manual scroll position", () => {
const scrollPosition: number = 500;
const isAutoScroll = scrollPosition === -1;
expect(isAutoScroll).toBe(false);
expect(scrollPosition).toBeGreaterThan(0);
});
});
describe("conversation deletion rules", () => {
it("prevents deletion of last conversation", () => {
const conversations = new Map([["conv-1", { id: "conv-1", name: "Main" }]]);
const canDelete = conversations.size > 1;
expect(canDelete).toBe(false);
});
it("allows deletion when multiple conversations exist", () => {
const conversations = new Map([
["conv-1", { id: "conv-1", name: "Main" }],
["conv-2", { id: "conv-2", name: "Second" }],
]);
const canDelete = conversations.size > 1;
expect(canDelete).toBe(true);
});
it("switches to remaining conversation after deletion", () => {
const conversations = new Map([
["conv-1", { id: "conv-1", name: "Main" }],
["conv-2", { id: "conv-2", name: "Second" }],
]);
const activeId = "conv-1";
conversations.delete(activeId);
const remaining = Array.from(conversations.keys());
expect(remaining).toEqual(["conv-2"]);
expect(remaining[0]).toBe("conv-2");
});
});
describe("activity timestamp tracking", () => {
it("updates lastActivityAt on changes", () => {
const before = new Date();
// Simulate a small delay
const after = new Date(before.getTime() + 100);
expect(after.getTime()).toBeGreaterThan(before.getTime());
});
});
describe("Map operations for conversations", () => {
it("stores and retrieves conversations by ID", () => {
const conversations = new Map<string, { id: string; name: string }>();
conversations.set("conv-1", { id: "conv-1", name: "First" });
conversations.set("conv-2", { id: "conv-2", name: "Second" });
expect(conversations.get("conv-1")?.name).toBe("First");
expect(conversations.get("conv-2")?.name).toBe("Second");
expect(conversations.get("conv-3")).toBeUndefined();
});
it("checks if conversation exists", () => {
const conversations = new Map([["conv-1", { id: "conv-1" }]]);
expect(conversations.has("conv-1")).toBe(true);
expect(conversations.has("conv-2")).toBe(false);
});
it("iterates over all conversations", () => {
const conversations = new Map([
["conv-1", { id: "conv-1", name: "First" }],
["conv-2", { id: "conv-2", name: "Second" }],
]);
const names: string[] = [];
conversations.forEach((conv) => names.push(conv.name));
expect(names).toContain("First");
expect(names).toContain("Second");
});
});
describe("conversation rename", () => {
it("updates conversation name", () => {
const conversation = { id: "conv-1", name: "Old Name", lastActivityAt: new Date() };
conversation.name = "New Name";
conversation.lastActivityAt = new Date();
expect(conversation.name).toBe("New Name");
});
});
describe("attachment management", () => {
it("adds attachment to array", () => {
const attachments: Array<{ id: string; name: string }> = [];
attachments.push({ id: "att-1", name: "file1.txt" });
expect(attachments).toHaveLength(1);
attachments.push({ id: "att-2", name: "file2.txt" });
expect(attachments).toHaveLength(2);
});
it("removes attachment by ID", () => {
const attachments = [
{ id: "att-1", name: "file1.txt" },
{ id: "att-2", name: "file2.txt" },
];
const filtered = attachments.filter((a) => a.id !== "att-1");
expect(filtered).toHaveLength(1);
expect(filtered[0].id).toBe("att-2");
});
it("clears all attachments", () => {
const cleared: Array<{ id: string }> = [];
expect(cleared).toHaveLength(0);
});
});
describe("derived store behavior", () => {
it("derives connection status from active conversation", () => {
const activeConv = { connectionStatus: "connected" as const };
const derivedStatus = activeConv?.connectionStatus || "disconnected";
expect(derivedStatus).toBe("connected");
});
it("defaults to disconnected when no active conversation", () => {
const activeConv = null as { connectionStatus?: string } | null;
const derivedStatus = activeConv?.connectionStatus || "disconnected";
expect(derivedStatus).toBe("disconnected");
});
it("derives terminal lines from active conversation", () => {
const activeConv = {
terminalLines: [
{ id: "1", content: "Hello" },
{ id: "2", content: "World" },
],
};
const derivedLines = activeConv?.terminalLines || [];
expect(derivedLines).toHaveLength(2);
});
it("defaults to empty array when no active conversation", () => {
const activeConv = null as { terminalLines?: Array<{ id: string; content: string }> } | null;
const derivedLines = activeConv?.terminalLines || [];
expect(derivedLines).toEqual([]);
});
});
describe("line update operations", () => {
it("updates line content by ID", () => {
const lines = [
{ id: "line-1", content: "Original" },
{ id: "line-2", content: "Other" },
];
const line = lines.find((l) => l.id === "line-1");
if (line) {
line.content = "Updated";
}
expect(lines[0].content).toBe("Updated");
expect(lines[1].content).toBe("Other");
});
it("appends to line content", () => {
const line = { id: "line-1", content: "Hello" };
line.content += " World";
expect(line.content).toBe("Hello World");
});
});
describe("pending retry message", () => {
it("stores and clears retry message", () => {
let pendingRetryMessage: string | null = null;
pendingRetryMessage = "Retry this message";
expect(pendingRetryMessage).toBe("Retry this message");
pendingRetryMessage = null;
expect(pendingRetryMessage).toBeNull();
});
});
+426
View File
@@ -0,0 +1,426 @@
import { writable, derived, get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import type { EditorState, EditorTab, FileEntry } from "$lib/types/editor";
const defaultState: EditorState = {
tabs: [],
activeTabId: null,
isFileBrowserOpen: true,
currentDirectory: "",
fileTree: [],
isLoadingTree: false,
};
function getLanguageFromPath(filePath: string): string {
const ext = filePath.split(".").pop()?.toLowerCase() || "";
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
rs: "rust",
go: "go",
java: "java",
c: "c",
cpp: "cpp",
h: "c",
hpp: "cpp",
cs: "csharp",
rb: "ruby",
php: "php",
swift: "swift",
kt: "kotlin",
scala: "scala",
html: "html",
htm: "html",
css: "css",
scss: "scss",
sass: "sass",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
md: "markdown",
markdown: "markdown",
sql: "sql",
sh: "shell",
bash: "shell",
zsh: "shell",
ps1: "powershell",
dockerfile: "dockerfile",
svelte: "svelte",
vue: "vue",
graphql: "graphql",
gql: "graphql",
lua: "lua",
r: "r",
dart: "dart",
elm: "elm",
ex: "elixir",
exs: "elixir",
erl: "erlang",
hs: "haskell",
clj: "clojure",
lisp: "lisp",
ml: "ocaml",
fs: "fsharp",
zig: "zig",
nim: "nim",
v: "v",
wasm: "wasm",
wat: "wasm",
};
return languageMap[ext] || "plaintext";
}
function generateTabId(): string {
return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
function createEditorStore() {
const state = writable<EditorState>(defaultState);
const isEditorVisible = writable<boolean>(false);
const saveError = writable<string | null>(null);
async function loadDirectory(dirPath: string): Promise<FileEntry[]> {
try {
const entries = await invoke<FileEntry[]>("list_directory", { path: dirPath });
return entries.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
} catch (error) {
console.error("Failed to load directory:", error);
return [];
}
}
async function initializeFileTree(rootPath: string) {
state.update((s) => ({ ...s, isLoadingTree: true, currentDirectory: rootPath }));
try {
const entries = await loadDirectory(rootPath);
state.update((s) => ({ ...s, fileTree: entries, isLoadingTree: false }));
} catch (error) {
console.error("Failed to initialize file tree:", error);
state.update((s) => ({ ...s, isLoadingTree: false }));
}
}
async function toggleDirectory(entry: FileEntry) {
if (!entry.isDirectory) return;
state.update((s) => {
const updateTree = (entries: FileEntry[]): FileEntry[] => {
return entries.map((e) => {
if (e.path === entry.path) {
return { ...e, isExpanded: !e.isExpanded, isLoading: !e.isExpanded && !e.children };
}
if (e.children) {
return { ...e, children: updateTree(e.children) };
}
return e;
});
};
return { ...s, fileTree: updateTree(s.fileTree) };
});
if (!entry.isExpanded && !entry.children) {
const children = await loadDirectory(entry.path);
state.update((s) => {
const updateTree = (entries: FileEntry[]): FileEntry[] => {
return entries.map((e) => {
if (e.path === entry.path) {
return { ...e, children, isLoading: false };
}
if (e.children) {
return { ...e, children: updateTree(e.children) };
}
return e;
});
};
return { ...s, fileTree: updateTree(s.fileTree) };
});
}
}
async function openFile(filePath: string) {
const currentState = get(state);
const existingTab = currentState.tabs.find((t) => t.filePath === filePath);
if (existingTab) {
state.update((s) => ({ ...s, activeTabId: existingTab.id }));
return;
}
try {
const content = await invoke<string>("read_file_content", { path: filePath });
const fileName = filePath.split(/[/\\]/).pop() || "untitled";
const language = getLanguageFromPath(filePath);
const newTab: EditorTab = {
id: generateTabId(),
filePath,
fileName,
content,
originalContent: content,
isDirty: false,
language,
};
state.update((s) => ({
...s,
tabs: [...s.tabs, newTab],
activeTabId: newTab.id,
}));
} catch (error) {
console.error("Failed to open file:", error);
saveError.set(`Failed to open file: ${error}`);
}
}
async function saveFile(tabId?: string) {
const currentState = get(state);
const tab = tabId
? currentState.tabs.find((t) => t.id === tabId)
: currentState.tabs.find((t) => t.id === currentState.activeTabId);
if (!tab) return;
saveError.set(null);
try {
await invoke("write_file_content", { path: tab.filePath, content: tab.content });
state.update((s) => ({
...s,
tabs: s.tabs.map((t) =>
t.id === tab.id ? { ...t, originalContent: t.content, isDirty: false } : t
),
}));
} catch (error) {
console.error("Failed to save file:", error);
saveError.set(`Failed to save file: ${error}`);
throw error;
}
}
function updateTabContent(tabId: string, content: string) {
state.update((s) => ({
...s,
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, content, isDirty: content !== t.originalContent } : t
),
}));
}
function closeTab(tabId: string) {
state.update((s) => {
const tabIndex = s.tabs.findIndex((t) => t.id === tabId);
const newTabs = s.tabs.filter((t) => t.id !== tabId);
let newActiveId = s.activeTabId;
if (s.activeTabId === tabId) {
if (newTabs.length === 0) {
newActiveId = null;
} else if (tabIndex >= newTabs.length) {
newActiveId = newTabs[newTabs.length - 1].id;
} else {
newActiveId = newTabs[tabIndex].id;
}
}
return { ...s, tabs: newTabs, activeTabId: newActiveId };
});
}
function setActiveTab(tabId: string) {
state.update((s) => ({ ...s, activeTabId: tabId }));
}
function toggleFileBrowser() {
state.update((s) => ({ ...s, isFileBrowserOpen: !s.isFileBrowserOpen }));
}
function toggleEditor() {
isEditorVisible.update((v) => !v);
}
async function createFile(parentPath: string, fileName: string): Promise<boolean> {
const filePath = `${parentPath}/${fileName}`;
try {
await invoke("create_file", { path: filePath });
// Refresh the parent directory
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to create file:", error);
saveError.set(`Failed to create file: ${error}`);
return false;
}
}
async function createDirectory(parentPath: string, dirName: string): Promise<boolean> {
const dirPath = `${parentPath}/${dirName}`;
try {
await invoke("create_directory", { path: dirPath });
// Refresh the parent directory
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to create directory:", error);
saveError.set(`Failed to create directory: ${error}`);
return false;
}
}
async function deleteFile(filePath: string): Promise<boolean> {
try {
await invoke("delete_file", { path: filePath });
// Close the tab if it's open
const currentState = get(state);
const openTab = currentState.tabs.find((t) => t.filePath === filePath);
if (openTab) {
closeTab(openTab.id);
}
// Refresh the parent directory
const parentPath = filePath.substring(0, filePath.lastIndexOf("/"));
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to delete file:", error);
saveError.set(`Failed to delete file: ${error}`);
return false;
}
}
async function deleteDirectory(dirPath: string): Promise<boolean> {
try {
await invoke("delete_directory", { path: dirPath });
// Close any tabs that are in this directory
const currentState = get(state);
const tabsToClose = currentState.tabs.filter((t) => t.filePath.startsWith(dirPath + "/"));
tabsToClose.forEach((tab) => closeTab(tab.id));
// Refresh the parent directory
const parentPath = dirPath.substring(0, dirPath.lastIndexOf("/"));
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to delete directory:", error);
saveError.set(`Failed to delete directory: ${error}`);
return false;
}
}
async function refreshDirectory(dirPath: string) {
const currentState = get(state);
// If refreshing the root directory, reload the entire tree
if (dirPath === currentState.currentDirectory) {
const entries = await loadDirectory(dirPath);
state.update((s) => ({ ...s, fileTree: entries }));
return;
}
// Otherwise, update the specific directory in the tree
const children = await loadDirectory(dirPath);
state.update((s) => {
const updateTree = (entries: FileEntry[]): FileEntry[] => {
return entries.map((e) => {
if (e.path === dirPath) {
return { ...e, children, isExpanded: true };
}
if (e.children) {
return { ...e, children: updateTree(e.children) };
}
return e;
});
};
return { ...s, fileTree: updateTree(s.fileTree) };
});
}
async function renamePath(oldPath: string, newName: string): Promise<boolean> {
const parentPath = oldPath.substring(0, oldPath.lastIndexOf("/"));
const newPath = `${parentPath}/${newName}`;
try {
await invoke("rename_path", { oldPath, newPath });
// Update any open tabs that reference this path
state.update((s) => ({
...s,
tabs: s.tabs.map((t) => {
if (t.filePath === oldPath) {
// Exact match - this file was renamed
return {
...t,
filePath: newPath,
fileName: newName,
};
}
if (t.filePath.startsWith(oldPath + "/")) {
// File is inside a renamed directory
const relativePath = t.filePath.substring(oldPath.length);
return {
...t,
filePath: newPath + relativePath,
};
}
return t;
}),
}));
// Refresh the parent directory
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to rename:", error);
saveError.set(`Failed to rename: ${error}`);
return false;
}
}
function showEditor() {
isEditorVisible.set(true);
}
function hideEditor() {
isEditorVisible.set(false);
}
return {
state: { subscribe: state.subscribe },
isEditorVisible: { subscribe: isEditorVisible.subscribe },
saveError: { subscribe: saveError.subscribe },
initializeFileTree,
toggleDirectory,
openFile,
saveFile,
updateTabContent,
closeTab,
setActiveTab,
toggleFileBrowser,
toggleEditor,
showEditor,
hideEditor,
createFile,
createDirectory,
deleteFile,
deleteDirectory,
refreshDirectory,
renamePath,
tabs: derived(state, ($state) => $state.tabs),
activeTab: derived(state, ($state) => $state.tabs.find((t) => t.id === $state.activeTabId)),
activeTabId: derived(state, ($state) => $state.activeTabId),
fileTree: derived(state, ($state) => $state.fileTree),
isFileBrowserOpen: derived(state, ($state) => $state.isFileBrowserOpen),
isLoadingTree: derived(state, ($state) => $state.isLoadingTree),
currentDirectory: derived(state, ($state) => $state.currentDirectory),
hasDirtyTabs: derived(state, ($state) => $state.tabs.some((t) => t.isDirty)),
};
}
export const editorStore = createEditorStore();
+351
View File
@@ -0,0 +1,351 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { setMockInvokeResult } from "../../../vitest.setup";
import { quickActionsStore, type QuickAction } from "./quickActions";
describe("QuickAction interface", () => {
it("defines all required fields", () => {
const action: QuickAction = {
id: "action-123",
name: "Run Tests",
prompt: "Please run the tests",
icon: "play",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
expect(action.id).toBe("action-123");
expect(action.name).toBe("Run Tests");
expect(action.prompt).toBe("Please run the tests");
expect(action.icon).toBe("play");
expect(action.is_default).toBe(false);
expect(action.created_at).toBe("2024-01-01T00:00:00Z");
expect(action.updated_at).toBe("2024-01-01T00:00:00Z");
});
it("supports default actions", () => {
const defaultAction: QuickAction = {
id: "default-review-pr",
name: "Review PR",
prompt: "Please review this pull request",
icon: "git-pull-request",
is_default: true,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
expect(defaultAction.is_default).toBe(true);
expect(defaultAction.id).toContain("default-");
});
});
describe("quickActionsStore", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("store structure", () => {
it("has all expected methods", () => {
expect(typeof quickActionsStore.loadQuickActions).toBe("function");
expect(typeof quickActionsStore.saveQuickAction).toBe("function");
expect(typeof quickActionsStore.createQuickAction).toBe("function");
expect(typeof quickActionsStore.updateQuickAction).toBe("function");
expect(typeof quickActionsStore.deleteQuickAction).toBe("function");
expect(typeof quickActionsStore.resetDefaults).toBe("function");
});
it("has subscribable stores", () => {
expect(typeof quickActionsStore.actions.subscribe).toBe("function");
expect(typeof quickActionsStore.isLoading.subscribe).toBe("function");
});
});
describe("loadQuickActions", () => {
it("loads quick actions from backend", async () => {
const mockActions: QuickAction[] = [
{
id: "action-1",
name: "Action 1",
prompt: "prompt 1",
icon: "star",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
setMockInvokeResult("list_quick_actions", mockActions);
await quickActionsStore.loadQuickActions();
expect(invoke).toHaveBeenCalledWith("list_quick_actions");
});
it("handles load errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_quick_actions", new Error("Failed to load"));
await quickActionsStore.loadQuickActions();
expect(consoleSpy).toHaveBeenCalledWith("Failed to load quick actions:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("saveQuickAction", () => {
it("saves action and reloads list", async () => {
const action: QuickAction = {
id: "action-123",
name: "Test",
prompt: "test prompt",
icon: "star",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
setMockInvokeResult("save_quick_action", undefined);
setMockInvokeResult("list_quick_actions", [action]);
const result = await quickActionsStore.saveQuickAction(action);
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith("save_quick_action", { action });
});
it("handles save errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("save_quick_action", new Error("Failed to save"));
const action: QuickAction = {
id: "action-123",
name: "Test",
prompt: "test prompt",
icon: "star",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
const result = await quickActionsStore.saveQuickAction(action);
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith("Failed to save quick action:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("createQuickAction", () => {
it("creates new action with generated ID and timestamps", async () => {
setMockInvokeResult("save_quick_action", undefined);
setMockInvokeResult("list_quick_actions", []);
const result = await quickActionsStore.createQuickAction("My Action", "Do something", "star");
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith(
"save_quick_action",
expect.objectContaining({
action: expect.objectContaining({
name: "My Action",
prompt: "Do something",
icon: "star",
is_default: false,
}),
})
);
});
});
describe("updateQuickAction", () => {
it("updates existing action preserving created_at", async () => {
const existingAction: QuickAction = {
id: "action-123",
name: "Old Name",
prompt: "old prompt",
icon: "old-icon",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
setMockInvokeResult("list_quick_actions", [existingAction]);
setMockInvokeResult("save_quick_action", undefined);
const result = await quickActionsStore.updateQuickAction(
"action-123",
"New Name",
"new prompt",
"new-icon"
);
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith(
"save_quick_action",
expect.objectContaining({
action: expect.objectContaining({
id: "action-123",
name: "New Name",
prompt: "new prompt",
icon: "new-icon",
created_at: "2024-01-01T00:00:00Z",
}),
})
);
});
it("returns false for non-existent action", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_quick_actions", []);
const result = await quickActionsStore.updateQuickAction(
"non-existent",
"Name",
"prompt",
"icon"
);
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith("Quick action not found for update");
consoleSpy.mockRestore();
});
});
describe("deleteQuickAction", () => {
it("deletes action by ID", async () => {
setMockInvokeResult("delete_quick_action", undefined);
setMockInvokeResult("list_quick_actions", []);
const result = await quickActionsStore.deleteQuickAction("action-123");
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith("delete_quick_action", { actionId: "action-123" });
});
it("handles delete errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_quick_action", new Error("Cannot delete default action"));
const result = await quickActionsStore.deleteQuickAction("default-1");
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete quick action:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("resetDefaults", () => {
it("resets default actions", async () => {
setMockInvokeResult("reset_default_quick_actions", undefined);
setMockInvokeResult("list_quick_actions", []);
const result = await quickActionsStore.resetDefaults();
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith("reset_default_quick_actions");
});
it("handles reset errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("reset_default_quick_actions", new Error("Reset failed"));
const result = await quickActionsStore.resetDefaults();
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to reset default quick actions:",
expect.any(Error)
);
consoleSpy.mockRestore();
});
});
});
describe("quick action ID generation", () => {
it("generates unique custom action IDs", () => {
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const id1 = generateId();
const id2 = generateId();
expect(id1).toMatch(/^custom-\d+-[a-z0-9]+$/);
expect(id2).toMatch(/^custom-\d+-[a-z0-9]+$/);
expect(id1).not.toBe(id2);
});
});
describe("action validation", () => {
it("requires non-empty name", () => {
const action = { name: "" };
const isValid = action.name.trim().length > 0;
expect(isValid).toBe(false);
});
it("requires non-empty prompt", () => {
const action = { prompt: " " };
const isValid = action.prompt.trim().length > 0;
expect(isValid).toBe(false);
});
it("requires non-empty icon", () => {
const action = { icon: "star" };
const isValid = action.icon.trim().length > 0;
expect(isValid).toBe(true);
});
});
describe("action icons", () => {
it("supports various icon names", () => {
const validIcons = [
"git-pull-request",
"play",
"file-text",
"alert-circle",
"check-square",
"refresh-cw",
"star",
"code",
"terminal",
];
validIcons.forEach((icon) => {
expect(typeof icon).toBe("string");
expect(icon.length).toBeGreaterThan(0);
});
});
});
describe("action sorting", () => {
it("sorts default actions before custom actions", () => {
const actions = [
{ id: "custom-1", name: "Custom", is_default: false },
{ id: "default-1", name: "Default", is_default: true },
];
const sorted = [...actions].sort((a, b) => {
const defaultCmp = (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0);
if (defaultCmp !== 0) return defaultCmp;
return a.name.localeCompare(b.name);
});
expect(sorted[0].is_default).toBe(true);
expect(sorted[1].is_default).toBe(false);
});
it("sorts alphabetically within same default status", () => {
const actions = [
{ id: "default-2", name: "Zebra", is_default: true },
{ id: "default-1", name: "Apple", is_default: true },
];
const sorted = [...actions].sort((a, b) => {
const defaultCmp = (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0);
if (defaultCmp !== 0) return defaultCmp;
return a.name.localeCompare(b.name);
});
expect(sorted[0].name).toBe("Apple");
expect(sorted[1].name).toBe("Zebra");
});
});
+353
View File
@@ -0,0 +1,353 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { setMockInvokeResult } from "../../../vitest.setup";
import { snippetsStore, type Snippet } from "./snippets";
describe("Snippet interface", () => {
it("defines all required fields", () => {
const snippet: Snippet = {
id: "snippet-123",
name: "Git Status",
content: "git status",
category: "Git",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
expect(snippet.id).toBe("snippet-123");
expect(snippet.name).toBe("Git Status");
expect(snippet.content).toBe("git status");
expect(snippet.category).toBe("Git");
expect(snippet.is_default).toBe(false);
expect(snippet.created_at).toBe("2024-01-01T00:00:00Z");
expect(snippet.updated_at).toBe("2024-01-01T00:00:00Z");
});
it("supports default snippets", () => {
const defaultSnippet: Snippet = {
id: "default-git-status",
name: "Git Status",
content: "git status --short",
category: "Git",
is_default: true,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
expect(defaultSnippet.is_default).toBe(true);
});
});
describe("snippetsStore", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("store structure", () => {
it("has all expected methods", () => {
expect(typeof snippetsStore.loadSnippets).toBe("function");
expect(typeof snippetsStore.saveSnippet).toBe("function");
expect(typeof snippetsStore.createSnippet).toBe("function");
expect(typeof snippetsStore.updateSnippet).toBe("function");
expect(typeof snippetsStore.deleteSnippet).toBe("function");
expect(typeof snippetsStore.resetDefaults).toBe("function");
expect(typeof snippetsStore.setSelectedCategory).toBe("function");
});
it("has subscribable stores", () => {
expect(typeof snippetsStore.snippets.subscribe).toBe("function");
expect(typeof snippetsStore.categories.subscribe).toBe("function");
expect(typeof snippetsStore.filteredSnippets.subscribe).toBe("function");
expect(typeof snippetsStore.isLoading.subscribe).toBe("function");
expect(typeof snippetsStore.selectedCategory.subscribe).toBe("function");
});
});
describe("loadSnippets", () => {
it("loads snippets and categories from backend", async () => {
const mockSnippets: Snippet[] = [
{
id: "snippet-1",
name: "Snippet 1",
content: "content 1",
category: "Git",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
const mockCategories = ["Git", "Shell", "Docker"];
setMockInvokeResult("list_snippets", mockSnippets);
setMockInvokeResult("get_snippet_categories", mockCategories);
await snippetsStore.loadSnippets();
expect(invoke).toHaveBeenCalledWith("list_snippets");
expect(invoke).toHaveBeenCalledWith("get_snippet_categories");
});
it("handles load errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_snippets", new Error("Failed to load"));
await snippetsStore.loadSnippets();
expect(consoleSpy).toHaveBeenCalledWith("Failed to load snippets:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("saveSnippet", () => {
it("saves snippet and reloads list", async () => {
const snippet: Snippet = {
id: "snippet-123",
name: "Test",
content: "test content",
category: "Test",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
setMockInvokeResult("save_snippet", undefined);
setMockInvokeResult("list_snippets", [snippet]);
setMockInvokeResult("get_snippet_categories", ["Test"]);
const result = await snippetsStore.saveSnippet(snippet);
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith("save_snippet", { snippet });
});
it("handles save errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("save_snippet", new Error("Failed to save"));
const snippet: Snippet = {
id: "snippet-123",
name: "Test",
content: "test content",
category: "Test",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
const result = await snippetsStore.saveSnippet(snippet);
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith("Failed to save snippet:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("createSnippet", () => {
it("creates new snippet with generated ID and timestamps", async () => {
setMockInvokeResult("save_snippet", undefined);
setMockInvokeResult("list_snippets", []);
setMockInvokeResult("get_snippet_categories", ["Shell"]);
const result = await snippetsStore.createSnippet("My Snippet", "echo hello", "Shell");
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith(
"save_snippet",
expect.objectContaining({
snippet: expect.objectContaining({
name: "My Snippet",
content: "echo hello",
category: "Shell",
is_default: false,
}),
})
);
});
});
describe("updateSnippet", () => {
it("updates existing snippet preserving created_at", async () => {
const existingSnippet: Snippet = {
id: "snippet-123",
name: "Old Name",
content: "old content",
category: "Old Category",
is_default: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
setMockInvokeResult("list_snippets", [existingSnippet]);
setMockInvokeResult("save_snippet", undefined);
setMockInvokeResult("get_snippet_categories", ["New Category"]);
const result = await snippetsStore.updateSnippet(
"snippet-123",
"New Name",
"new content",
"New Category"
);
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith(
"save_snippet",
expect.objectContaining({
snippet: expect.objectContaining({
id: "snippet-123",
name: "New Name",
content: "new content",
category: "New Category",
created_at: "2024-01-01T00:00:00Z",
}),
})
);
});
it("returns false for non-existent snippet", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_snippets", []);
const result = await snippetsStore.updateSnippet(
"non-existent",
"Name",
"content",
"Category"
);
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith("Snippet not found for update");
consoleSpy.mockRestore();
});
});
describe("deleteSnippet", () => {
it("deletes snippet by ID", async () => {
setMockInvokeResult("delete_snippet", undefined);
setMockInvokeResult("list_snippets", []);
setMockInvokeResult("get_snippet_categories", []);
const result = await snippetsStore.deleteSnippet("snippet-123");
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith("delete_snippet", { snippetId: "snippet-123" });
});
it("handles delete errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_snippet", new Error("Cannot delete default snippet"));
const result = await snippetsStore.deleteSnippet("default-1");
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete snippet:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("resetDefaults", () => {
it("resets default snippets", async () => {
setMockInvokeResult("reset_default_snippets", undefined);
setMockInvokeResult("list_snippets", []);
setMockInvokeResult("get_snippet_categories", []);
const result = await snippetsStore.resetDefaults();
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith("reset_default_snippets");
});
it("handles reset errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("reset_default_snippets", new Error("Reset failed"));
const result = await snippetsStore.resetDefaults();
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to reset default snippets:",
expect.any(Error)
);
consoleSpy.mockRestore();
});
});
describe("setSelectedCategory", () => {
it("updates selected category", () => {
snippetsStore.setSelectedCategory("Git");
expect(get(snippetsStore.selectedCategory)).toBe("Git");
});
it("can be cleared with null", () => {
snippetsStore.setSelectedCategory("Git");
snippetsStore.setSelectedCategory(null);
expect(get(snippetsStore.selectedCategory)).toBeNull();
});
});
});
describe("snippet ID generation", () => {
it("generates unique custom snippet IDs", () => {
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const id1 = generateId();
const id2 = generateId();
expect(id1).toMatch(/^custom-\d+-[a-z0-9]+$/);
expect(id2).toMatch(/^custom-\d+-[a-z0-9]+$/);
expect(id1).not.toBe(id2);
});
});
describe("snippet validation", () => {
it("requires non-empty name", () => {
const snippet = { name: "" };
const isValid = snippet.name.trim().length > 0;
expect(isValid).toBe(false);
});
it("requires non-empty content", () => {
const snippet = { content: " " };
const isValid = snippet.content.trim().length > 0;
expect(isValid).toBe(false);
});
it("requires non-empty category", () => {
const snippet = { category: "Git" };
const isValid = snippet.category.trim().length > 0;
expect(isValid).toBe(true);
});
});
describe("snippet content types", () => {
it("supports multiline content", () => {
const snippet = {
content: `git add .
git commit -m "message"
git push`,
};
expect(snippet.content.includes("\n")).toBe(true);
expect(snippet.content.split("\n")).toHaveLength(3);
});
it("supports content with special characters", () => {
const snippet = {
content: "echo \"Hello, World!\" && echo 'Single quotes'",
};
expect(snippet.content).toContain('"');
expect(snippet.content).toContain("'");
expect(snippet.content).toContain("&&");
});
it("supports content with variables", () => {
const snippet = {
content: "docker run -v $PWD:/app ${IMAGE_NAME}:${TAG}",
};
expect(snippet.content).toContain("$PWD");
expect(snippet.content).toContain("${IMAGE_NAME}");
});
});
+346
View File
@@ -0,0 +1,346 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store";
import { stats, formattedStats, resetSessionStats } from "./stats";
import type { UsageStats } from "./stats";
// Mock Tauri APIs
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
describe("stats store", () => {
beforeEach(() => {
// Reset stats to default before each test
stats.set({
total_input_tokens: 0,
total_output_tokens: 0,
total_cost_usd: 0,
session_input_tokens: 0,
session_output_tokens: 0,
session_cost_usd: 0,
model: null,
messages_exchanged: 0,
session_messages_exchanged: 0,
code_blocks_generated: 0,
session_code_blocks_generated: 0,
files_edited: 0,
session_files_edited: 0,
files_created: 0,
session_files_created: 0,
tools_usage: {},
session_tools_usage: {},
session_duration_seconds: 0,
});
});
describe("stats writable store", () => {
it("has correct default values", () => {
const currentStats = get(stats);
expect(currentStats.total_input_tokens).toBe(0);
expect(currentStats.total_output_tokens).toBe(0);
expect(currentStats.total_cost_usd).toBe(0);
expect(currentStats.model).toBeNull();
});
it("can be updated with set", () => {
const newStats: UsageStats = {
total_input_tokens: 1000,
total_output_tokens: 2000,
total_cost_usd: 0.05,
session_input_tokens: 500,
session_output_tokens: 1000,
session_cost_usd: 0.025,
model: "claude-sonnet-4",
messages_exchanged: 10,
session_messages_exchanged: 5,
code_blocks_generated: 3,
session_code_blocks_generated: 2,
files_edited: 5,
session_files_edited: 2,
files_created: 1,
session_files_created: 1,
tools_usage: { Read: 5, Edit: 3 },
session_tools_usage: { Read: 2, Edit: 1 },
session_duration_seconds: 300,
};
stats.set(newStats);
const currentStats = get(stats);
expect(currentStats.total_input_tokens).toBe(1000);
expect(currentStats.total_output_tokens).toBe(2000);
expect(currentStats.model).toBe("claude-sonnet-4");
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
});
it("can be updated with update function", () => {
stats.update((current) => ({
...current,
total_input_tokens: 500,
session_messages_exchanged: 3,
}));
const currentStats = get(stats);
expect(currentStats.total_input_tokens).toBe(500);
expect(currentStats.session_messages_exchanged).toBe(3);
});
});
describe("resetSessionStats", () => {
it("resets all session fields to zero", () => {
// First set some values
stats.set({
total_input_tokens: 1000,
total_output_tokens: 2000,
total_cost_usd: 0.05,
session_input_tokens: 500,
session_output_tokens: 1000,
session_cost_usd: 0.025,
model: "claude-sonnet-4",
messages_exchanged: 10,
session_messages_exchanged: 5,
code_blocks_generated: 3,
session_code_blocks_generated: 2,
files_edited: 5,
session_files_edited: 2,
files_created: 1,
session_files_created: 1,
tools_usage: { Read: 5, Edit: 3 },
session_tools_usage: { Read: 2, Edit: 1 },
session_duration_seconds: 300,
});
// Reset session stats
resetSessionStats();
const currentStats = get(stats);
// Total stats should be preserved
expect(currentStats.total_input_tokens).toBe(1000);
expect(currentStats.total_output_tokens).toBe(2000);
expect(currentStats.total_cost_usd).toBe(0.05);
expect(currentStats.messages_exchanged).toBe(10);
expect(currentStats.code_blocks_generated).toBe(3);
expect(currentStats.files_edited).toBe(5);
expect(currentStats.files_created).toBe(1);
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
expect(currentStats.model).toBe("claude-sonnet-4");
// Session stats should be reset
expect(currentStats.session_input_tokens).toBe(0);
expect(currentStats.session_output_tokens).toBe(0);
expect(currentStats.session_cost_usd).toBe(0);
expect(currentStats.session_messages_exchanged).toBe(0);
expect(currentStats.session_code_blocks_generated).toBe(0);
expect(currentStats.session_files_edited).toBe(0);
expect(currentStats.session_files_created).toBe(0);
expect(currentStats.session_tools_usage).toEqual({});
expect(currentStats.session_duration_seconds).toBe(0);
});
});
describe("formattedStats derived store", () => {
it("formats token numbers with locale string", () => {
stats.update((current) => ({
...current,
total_input_tokens: 1234567,
total_output_tokens: 7654321,
session_input_tokens: 12345,
session_output_tokens: 54321,
}));
const formatted = get(formattedStats);
expect(formatted.totalTokens).toBe("8,888,888");
expect(formatted.totalInputTokens).toBe("1,234,567");
expect(formatted.totalOutputTokens).toBe("7,654,321");
expect(formatted.sessionTokens).toBe("66,666");
expect(formatted.sessionInputTokens).toBe("12,345");
expect(formatted.sessionOutputTokens).toBe("54,321");
});
it("formats cost with 4 decimal places", () => {
stats.update((current) => ({
...current,
total_cost_usd: 1.23456,
session_cost_usd: 0.00123,
}));
const formatted = get(formattedStats);
expect(formatted.totalCost).toBe("$1.2346");
expect(formatted.sessionCost).toBe("$0.0012");
});
it("formats duration seconds only", () => {
stats.update((current) => ({
...current,
session_duration_seconds: 45,
}));
const formatted = get(formattedStats);
expect(formatted.sessionDuration).toBe("45s");
});
it("formats duration minutes and seconds", () => {
stats.update((current) => ({
...current,
session_duration_seconds: 125, // 2m 5s
}));
const formatted = get(formattedStats);
expect(formatted.sessionDuration).toBe("2m 5s");
});
it("formats duration hours, minutes, and seconds", () => {
stats.update((current) => ({
...current,
session_duration_seconds: 3725, // 1h 2m 5s
}));
const formatted = get(formattedStats);
expect(formatted.sessionDuration).toBe("1h 2m 5s");
});
it("formats duration with zero seconds", () => {
stats.update((current) => ({
...current,
session_duration_seconds: 3600, // exactly 1h
}));
const formatted = get(formattedStats);
expect(formatted.sessionDuration).toBe("1h 0m 0s");
});
it("shows model name when available", () => {
stats.update((current) => ({
...current,
model: "claude-opus-4-5",
}));
const formatted = get(formattedStats);
expect(formatted.model).toBe("claude-opus-4-5");
});
it("shows placeholder when model is null", () => {
stats.update((current) => ({
...current,
model: null,
}));
const formatted = get(formattedStats);
expect(formatted.model).toBe("No model selected");
});
it("formats message counts", () => {
stats.update((current) => ({
...current,
messages_exchanged: 100,
session_messages_exchanged: 10,
}));
const formatted = get(formattedStats);
expect(formatted.messagesTotal).toBe("100");
expect(formatted.messagesSession).toBe("10");
});
it("formats code block counts", () => {
stats.update((current) => ({
...current,
code_blocks_generated: 50,
session_code_blocks_generated: 5,
}));
const formatted = get(formattedStats);
expect(formatted.codeBlocksTotal).toBe("50");
expect(formatted.codeBlocksSession).toBe("5");
});
it("formats file counts", () => {
stats.update((current) => ({
...current,
files_edited: 25,
session_files_edited: 3,
files_created: 10,
session_files_created: 2,
}));
const formatted = get(formattedStats);
expect(formatted.filesEditedTotal).toBe("25");
expect(formatted.filesEditedSession).toBe("3");
expect(formatted.filesCreatedTotal).toBe("10");
expect(formatted.filesCreatedSession).toBe("2");
});
it("exposes tools usage directly", () => {
const toolsUsage = { Read: 10, Edit: 5, Write: 3 };
const sessionToolsUsage = { Read: 2, Edit: 1 };
stats.update((current) => ({
...current,
tools_usage: toolsUsage,
session_tools_usage: sessionToolsUsage,
}));
const formatted = get(formattedStats);
expect(formatted.toolsUsage).toEqual(toolsUsage);
expect(formatted.sessionToolsUsage).toEqual(sessionToolsUsage);
});
it("handles zero values correctly", () => {
const formatted = get(formattedStats);
expect(formatted.totalTokens).toBe("0");
expect(formatted.totalCost).toBe("$0.0000");
expect(formatted.sessionDuration).toBe("0s");
expect(formatted.messagesTotal).toBe("0");
});
it("handles large numbers with proper formatting", () => {
stats.update((current) => ({
...current,
total_input_tokens: 1000000000, // 1 billion
messages_exchanged: 999999,
}));
const formatted = get(formattedStats);
expect(formatted.totalInputTokens).toBe("1,000,000,000");
expect(formatted.messagesTotal).toBe("999,999");
});
});
describe("UsageStats interface", () => {
it("supports all expected fields", () => {
const fullStats: UsageStats = {
total_input_tokens: 100,
total_output_tokens: 200,
total_cost_usd: 0.01,
session_input_tokens: 50,
session_output_tokens: 100,
session_cost_usd: 0.005,
model: "test-model",
messages_exchanged: 5,
session_messages_exchanged: 2,
code_blocks_generated: 3,
session_code_blocks_generated: 1,
files_edited: 2,
session_files_edited: 1,
files_created: 1,
session_files_created: 0,
tools_usage: { Read: 3 },
session_tools_usage: { Read: 1 },
session_duration_seconds: 60,
};
stats.set(fullStats);
const currentStats = get(stats);
// Verify all fields are present and correct
expect(currentStats).toEqual(fullStats);
});
});
});
+388
View File
@@ -0,0 +1,388 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { emitMockEvent, setMockInvokeResult } from "../../vitest.setup";
// We need to test the helper functions that are exported
// The main listener initialization is tested through integration
describe("tauri helpers", () => {
describe("getTimeOfDay (inferred from greeting behavior)", () => {
it("returns morning for hours 5-11", () => {
const date = new Date();
date.setHours(8, 0, 0, 0);
vi.setSystemTime(date);
// The getTimeOfDay function is private, but we can verify the greeting prompt includes the time
// This tests the logic indirectly
expect(date.getHours()).toBeGreaterThanOrEqual(5);
expect(date.getHours()).toBeLessThan(12);
});
it("returns afternoon for hours 12-16", () => {
const date = new Date();
date.setHours(14, 0, 0, 0);
vi.setSystemTime(date);
expect(date.getHours()).toBeGreaterThanOrEqual(12);
expect(date.getHours()).toBeLessThan(17);
});
it("returns evening for hours 17-20", () => {
const date = new Date();
date.setHours(19, 0, 0, 0);
vi.setSystemTime(date);
expect(date.getHours()).toBeGreaterThanOrEqual(17);
expect(date.getHours()).toBeLessThan(21);
});
it("returns late night for hours 21-4", () => {
const date = new Date();
date.setHours(23, 0, 0, 0);
vi.setSystemTime(date);
expect(date.getHours() >= 21 || date.getHours() < 5).toBe(true);
});
});
});
describe("tauri event handling", () => {
beforeEach(() => {
vi.useFakeTimers();
});
describe("connection events", () => {
it("emits connected status payload", () => {
const payload = {
status: "connected",
conversation_id: "test-conv-1",
};
// Verify payload structure
expect(payload.status).toBe("connected");
expect(payload.conversation_id).toBe("test-conv-1");
});
it("emits disconnected status payload", () => {
const payload = {
status: "disconnected",
conversation_id: "test-conv-1",
};
expect(payload.status).toBe("disconnected");
});
it("emits error status payload", () => {
const payload = {
status: "error",
conversation_id: "test-conv-1",
};
expect(payload.status).toBe("error");
});
});
describe("state change events", () => {
it("maps idle state correctly", () => {
const payload = {
state: "idle",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.state.toLowerCase()).toBe("idle");
});
it("maps thinking state correctly", () => {
const payload = {
state: "thinking",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.state.toLowerCase()).toBe("thinking");
});
it("maps typing state correctly", () => {
const payload = {
state: "typing",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.state.toLowerCase()).toBe("typing");
});
it("maps searching state correctly", () => {
const payload = {
state: "searching",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.state.toLowerCase()).toBe("searching");
});
it("maps coding state correctly", () => {
const payload = {
state: "coding",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.state.toLowerCase()).toBe("coding");
});
it("maps mcp state correctly", () => {
const payload = {
state: "mcp",
tool_name: "some-tool",
conversation_id: "test-conv-1",
};
expect(payload.state.toLowerCase()).toBe("mcp");
expect(payload.tool_name).toBe("some-tool");
});
it("maps permission state correctly", () => {
const payload = {
state: "permission",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.state.toLowerCase()).toBe("permission");
});
it("maps success state correctly", () => {
const payload = {
state: "success",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.state.toLowerCase()).toBe("success");
});
it("maps error state correctly", () => {
const payload = {
state: "error",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.state.toLowerCase()).toBe("error");
});
it("defaults unknown state to idle", () => {
const stateMap: Record<string, string> = {
idle: "idle",
thinking: "thinking",
typing: "typing",
searching: "searching",
coding: "coding",
mcp: "mcp",
permission: "permission",
success: "success",
error: "error",
};
const unknownState = "unknown-state";
const mappedState = stateMap[unknownState.toLowerCase()] || "idle";
expect(mappedState).toBe("idle");
});
});
describe("output events", () => {
it("handles user output type", () => {
const payload = {
line_type: "user",
content: "Hello, world!",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.line_type).toBe("user");
expect(payload.content).toBe("Hello, world!");
});
it("handles assistant output type", () => {
const payload = {
line_type: "assistant",
content: "Hi there!",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.line_type).toBe("assistant");
});
it("handles system output type", () => {
const payload = {
line_type: "system",
content: "Connected to Claude Code",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.line_type).toBe("system");
});
it("handles tool output type with tool name", () => {
const payload = {
line_type: "tool",
content: "Tool executed successfully",
tool_name: "Read",
conversation_id: "test-conv-1",
};
expect(payload.line_type).toBe("tool");
expect(payload.tool_name).toBe("Read");
});
it("handles error output type", () => {
const payload = {
line_type: "error",
content: "An error occurred",
tool_name: null,
conversation_id: "test-conv-1",
};
expect(payload.line_type).toBe("error");
});
});
describe("session events", () => {
it("handles session payload with conversation id", () => {
const payload = {
session_id: "sess-12345678",
conversation_id: "test-conv-1",
};
expect(payload.session_id).toBe("sess-12345678");
expect(payload.conversation_id).toBe("test-conv-1");
});
it("creates truncated session display", () => {
const sessionId = "sess-12345678-90ab-cdef";
const display = `Session: ${sessionId.substring(0, 8)}...`;
expect(display).toBe("Session: sess-123...");
});
});
describe("working directory events", () => {
it("handles cwd payload", () => {
const payload = {
directory: "/home/user/project",
conversation_id: "test-conv-1",
};
expect(payload.directory).toBe("/home/user/project");
});
});
describe("permission events", () => {
it("handles permission payload structure", () => {
const payload = {
id: "perm-123",
tool_name: "Bash",
tool_input: '{"command": "ls -la"}',
description: "Run shell command",
conversation_id: "test-conv-1",
};
expect(payload.id).toBe("perm-123");
expect(payload.tool_name).toBe("Bash");
expect(payload.tool_input).toContain("command");
expect(payload.description).toBe("Run shell command");
});
});
describe("question events", () => {
it("handles question payload structure", () => {
const payload = {
id: "q-123",
question: "Which option would you like?",
options: [
{ label: "Option A", description: "First option" },
{ label: "Option B", description: "Second option" },
],
conversation_id: "test-conv-1",
};
expect(payload.id).toBe("q-123");
expect(payload.question).toBe("Which option would you like?");
expect(payload.options).toHaveLength(2);
});
});
});
describe("mock event system", () => {
it("can emit events through mock system", () => {
// The emitMockEvent function should work
expect(typeof emitMockEvent).toBe("function");
});
it("can set mock invoke results", () => {
setMockInvokeResult("test_command", { result: "success" });
// This verifies the mock setup is working
expect(typeof setMockInvokeResult).toBe("function");
});
});
describe("greeting system", () => {
it("generates greeting prompt with time of day", () => {
const timeOfDay = "morning";
const prompt = `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
expect(prompt).toContain("morning");
expect(prompt).toContain("greet the user");
});
it("uses custom greeting prompt when provided", () => {
const customPrompt = "Say hello in pirate speak!";
const greetingPrompt = customPrompt.trim() || "default greeting";
expect(greetingPrompt).toBe("Say hello in pirate speak!");
});
it("uses default prompt when custom is empty", () => {
const customPrompt = " ";
const defaultPrompt = "default greeting";
const greetingPrompt = customPrompt.trim() || defaultPrompt;
expect(greetingPrompt).toBe(defaultPrompt);
});
});
describe("conversation tracking", () => {
it("tracks connected conversations with Set", () => {
const connectedConversations = new Set<string>();
connectedConversations.add("conv-1");
expect(connectedConversations.has("conv-1")).toBe(true);
expect(connectedConversations.size).toBe(1);
connectedConversations.add("conv-2");
expect(connectedConversations.size).toBe(2);
connectedConversations.delete("conv-1");
expect(connectedConversations.has("conv-1")).toBe(false);
expect(connectedConversations.size).toBe(1);
});
});
describe("skip greeting flag", () => {
it("flag can be set and reset", () => {
let skipNextGreeting = false;
skipNextGreeting = true;
expect(skipNextGreeting).toBe(true);
// Simulate reset after use
if (skipNextGreeting) {
skipNextGreeting = false;
}
expect(skipNextGreeting).toBe(false);
});
});
+27
View File
@@ -0,0 +1,27 @@
export interface FileEntry {
name: string;
path: string;
isDirectory: boolean;
isExpanded?: boolean;
children?: FileEntry[];
isLoading?: boolean;
}
export interface EditorTab {
id: string;
filePath: string;
fileName: string;
content: string;
originalContent: string;
isDirty: boolean;
language: string;
}
export interface EditorState {
tabs: EditorTab[];
activeTabId: string | null;
isFileBrowserOpen: boolean;
currentDirectory: string;
fileTree: FileEntry[];
isLoadingTree: boolean;
}
+9 -1
View File
@@ -1,9 +1,17 @@
<script> <script lang="ts">
import "../app.css"; import "../app.css";
let { children } = $props(); let { children } = $props();
// Prevent the default context menu globally
// Individual components can show their own context menus by calling event.stopPropagation()
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
}
</script> </script>
<svelte:window oncontextmenu={handleContextMenu} />
<div id="app"> <div id="app">
{@render children()} {@render children()}
</div> </div>
+78 -3
View File
@@ -7,6 +7,7 @@
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications"; import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude"; import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { editorStore } from "$lib/stores/editor";
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
import "$lib/notifications/testNotifications"; import "$lib/notifications/testNotifications";
import Terminal from "$lib/components/Terminal.svelte"; import Terminal from "$lib/components/Terminal.svelte";
@@ -14,6 +15,7 @@
import StatusBar from "$lib/components/StatusBar.svelte"; import StatusBar from "$lib/components/StatusBar.svelte";
import AnimeGirl from "$lib/components/AnimeGirl.svelte"; import AnimeGirl from "$lib/components/AnimeGirl.svelte";
import CompactMode from "$lib/components/CompactMode.svelte"; import CompactMode from "$lib/components/CompactMode.svelte";
import EditorPanel from "$lib/components/editor/EditorPanel.svelte";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import type { CharacterState } from "$lib/types/states"; import type { CharacterState } from "$lib/types/states";
import PermissionModal from "$lib/components/PermissionModal.svelte"; import PermissionModal from "$lib/components/PermissionModal.svelte";
@@ -29,6 +31,32 @@
let currentCharacterState: CharacterState = $state("idle"); let currentCharacterState: CharacterState = $state("idle");
let compactModeActive = $state(false); let compactModeActive = $state(false);
// Editor state
const isEditorVisible = editorStore.isEditorVisible;
let lastInitializedCwd = "";
// Track connection status and CWD for the editor
const connectionStatus = claudeStore.connectionStatus;
const currentWorkingDirectory = claudeStore.currentWorkingDirectory;
// Initialize/update editor file tree when CWD changes while editor is visible
$effect(() => {
const visible = $isEditorVisible;
const cwd = $currentWorkingDirectory;
const connected = $connectionStatus === "connected";
// Only initialize when editor is visible, connected, and CWD is set
if (visible && connected && cwd && cwd !== lastInitializedCwd) {
lastInitializedCwd = cwd;
editorStore.initializeFileTree(cwd);
}
// Hide editor if disconnected
if (!connected && visible) {
editorStore.hideEditor();
}
});
// Window size constants // Window size constants
const COMPACT_WIDTH = 280; const COMPACT_WIDTH = 280;
const COMPACT_HEIGHT = 400; const COMPACT_HEIGHT = 400;
@@ -176,6 +204,49 @@
toggleCompactMode(); toggleCompactMode();
return; return;
} }
// Ctrl+E - Toggle editor panel (only when connected)
if (event.ctrlKey && event.key === "e") {
event.preventDefault();
// Only allow opening the editor when connected
if (get(claudeStore.connectionStatus) === "connected") {
editorStore.toggleEditor();
}
return;
}
// Ctrl+B - Toggle file browser (when editor is visible)
if (event.ctrlKey && event.key === "b" && get(editorStore.isEditorVisible)) {
event.preventDefault();
editorStore.toggleFileBrowser();
return;
}
// Ctrl+S - Save current file (when editor is visible)
if (event.ctrlKey && event.key === "s" && get(editorStore.isEditorVisible)) {
event.preventDefault();
editorStore.saveFile();
return;
}
// Ctrl+W - Close current tab (when editor is visible)
if (event.ctrlKey && event.key === "w" && get(editorStore.isEditorVisible)) {
event.preventDefault();
const activeTabId = get(editorStore.activeTabId);
if (activeTabId) {
editorStore.closeTab(activeTabId);
}
return;
}
// Ctrl+N - New file (when editor is visible)
// Note: This just emits an event that FileBrowser listens to
if (event.ctrlKey && event.key === "n" && get(editorStore.isEditorVisible)) {
event.preventDefault();
// Dispatch a custom event that FileBrowser will listen to
window.dispatchEvent(new CustomEvent("editor-new-file"));
return;
}
} }
async function handleInterrupt() { async function handleInterrupt() {
@@ -330,10 +401,14 @@
onmousedown={startResize} onmousedown={startResize}
></div> ></div>
<!-- Right panel: Terminal and input --> <!-- Right panel: Terminal/Editor and input -->
<div class="terminal-panel flex-1 flex flex-col min-w-0"> <div class="terminal-panel flex-1 flex flex-col min-w-0">
<Terminal /> {#if $isEditorVisible}
<InputBar /> <EditorPanel />
{:else}
<Terminal />
<InputBar />
{/if}
</div> </div>
</main> </main>
+18
View File
@@ -8,5 +8,23 @@ export default defineConfig({
environment: "jsdom", environment: "jsdom",
setupFiles: ["./vitest.setup.ts"], setupFiles: ["./vitest.setup.ts"],
globals: true, globals: true,
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
reportsDirectory: "./coverage",
include: ["src/lib/**/*.ts"],
exclude: [
"src/lib/**/*.test.ts",
"src/lib/**/*.spec.ts",
"src/lib/**/*.d.ts",
"src/lib/components/**",
],
thresholds: {
statements: 15,
branches: 13,
functions: 13,
lines: 15,
},
},
}, },
}); });
+287
View File
@@ -1 +1,288 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { vi, beforeEach } from "vitest";
// Mock Tauri invoke API
const mockInvokeResults: Record<string, unknown> = {};
export function setMockInvokeResult(command: string, result: unknown) {
mockInvokeResults[command] = result;
}
export function clearMockInvokeResults() {
Object.keys(mockInvokeResults).forEach((key) => delete mockInvokeResults[key]);
}
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn((command: string) => {
if (command in mockInvokeResults) {
const result = mockInvokeResults[command];
if (result instanceof Error) {
return Promise.reject(result);
}
return Promise.resolve(result);
}
// Default return values for common commands
switch (command) {
case "get_config":
return Promise.resolve({
model: null,
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
auto_granted_tools: [],
theme: "dark",
greeting_enabled: true,
greeting_custom_prompt: null,
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {},
});
case "list_quick_actions":
return Promise.resolve([]);
case "list_snippets":
return Promise.resolve([]);
case "list_sessions":
return Promise.resolve([]);
case "get_usage_stats":
return Promise.resolve({
total_messages: 0,
total_sessions: 0,
total_tokens: 0,
total_cost: 0,
});
case "get_persisted_stats":
return Promise.resolve({
lifetime_messages: 0,
lifetime_sessions: 0,
lifetime_tokens: 0,
lifetime_cost: 0,
achievements: [],
unlocked_achievements: [],
});
case "load_saved_achievements":
return Promise.resolve([]);
case "list_clipboard_entries":
return Promise.resolve([]);
case "cleanup_temp_files":
return Promise.resolve();
case "validate_directory":
return Promise.resolve(true);
case "git_status":
return Promise.resolve({
branch: "main",
files: [],
ahead: 0,
behind: 0,
});
default:
return Promise.resolve(null);
}
}),
}));
// Mock Tauri event API
const eventListeners: Map<string, Array<(event: { payload: unknown }) => void>> = new Map();
export function emitMockEvent(eventName: string, payload: unknown) {
const listeners = eventListeners.get(eventName) || [];
listeners.forEach((listener) => listener({ payload }));
}
export function clearMockEventListeners() {
eventListeners.clear();
}
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn((eventName: string, handler: (event: { payload: unknown }) => void) => {
const listeners = eventListeners.get(eventName) || [];
listeners.push(handler);
eventListeners.set(eventName, listeners);
// Return an unlisten function
return Promise.resolve(() => {
const currentListeners = eventListeners.get(eventName) || [];
const index = currentListeners.indexOf(handler);
if (index > -1) {
currentListeners.splice(index, 1);
}
});
}),
emit: vi.fn(),
}));
// Mock Tauri plugins
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: vi.fn(() => Promise.resolve(null)),
open: vi.fn(() => Promise.resolve(null)),
message: vi.fn(() => Promise.resolve()),
ask: vi.fn(() => Promise.resolve(true)),
confirm: vi.fn(() => Promise.resolve(true)),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
writeTextFile: vi.fn(() => Promise.resolve()),
readTextFile: vi.fn(() => Promise.resolve("{}")),
exists: vi.fn(() => Promise.resolve(false)),
mkdir: vi.fn(() => Promise.resolve()),
remove: vi.fn(() => Promise.resolve()),
readDir: vi.fn(() => Promise.resolve([])),
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
openPath: vi.fn(() => Promise.resolve()),
openUrl: vi.fn(() => Promise.resolve()),
}));
vi.mock("@tauri-apps/plugin-notification", () => ({
sendNotification: vi.fn(() => Promise.resolve()),
requestPermission: vi.fn(() => Promise.resolve("granted")),
isPermissionGranted: vi.fn(() => Promise.resolve(true)),
}));
vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({
writeText: vi.fn(() => Promise.resolve()),
readText: vi.fn(() => Promise.resolve("")),
writeImage: vi.fn(() => Promise.resolve()),
readImage: vi.fn(() => Promise.resolve(null)),
}));
vi.mock("@tauri-apps/plugin-os", () => ({
platform: vi.fn(() => Promise.resolve("linux")),
arch: vi.fn(() => Promise.resolve("x86_64")),
type: vi.fn(() => Promise.resolve("Linux")),
version: vi.fn(() => Promise.resolve("1.0.0")),
}));
vi.mock("@tauri-apps/plugin-http", () => ({
fetch: vi.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({}),
text: () => Promise.resolve(""),
})
),
}));
// Mock browser APIs
class MockAudioElement {
src = "";
volume = 1;
loop = false;
currentTime = 0;
paused = true;
preload = "auto";
onloadeddata: (() => void) | null = null;
onended: (() => void) | null = null;
onerror: ((e: Event) => void) | null = null;
play() {
this.paused = false;
return Promise.resolve();
}
pause() {
this.paused = true;
}
load() {
if (this.onloadeddata) {
setTimeout(() => this.onloadeddata?.(), 0);
}
}
addEventListener(event: string, handler: () => void) {
if (event === "loadeddata") this.onloadeddata = handler;
if (event === "ended") this.onended = handler;
}
removeEventListener() {
// No-op for tests
}
}
// @ts-expect-error - Mock Audio constructor
globalThis.Audio = MockAudioElement;
// Mock localStorage
const localStorageStore: Record<string, string> = {};
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: vi.fn((key: string) => localStorageStore[key] || null),
setItem: vi.fn((key: string, value: string) => {
localStorageStore[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete localStorageStore[key];
}),
clear: vi.fn(() => {
Object.keys(localStorageStore).forEach((key) => delete localStorageStore[key]);
}),
key: vi.fn((index: number) => Object.keys(localStorageStore)[index] || null),
get length() {
return Object.keys(localStorageStore).length;
},
},
writable: true,
});
// Mock matchMedia
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn((query: string) => ({
matches: query.includes("dark"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock ResizeObserver
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
globalThis.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
// Mock requestAnimationFrame
globalThis.requestAnimationFrame = vi.fn((callback) => {
return setTimeout(callback, 0) as unknown as number;
});
globalThis.cancelAnimationFrame = vi.fn((id) => {
clearTimeout(id);
});
// Reset all mocks before each test
beforeEach(() => {
vi.clearAllMocks();
clearMockInvokeResults();
clearMockEventListeners();
});