Compare commits

..

19 Commits

Author SHA1 Message Date
hikari 73d66e9ae4 feat: add shareable profile image generation
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 54s
CI / Lint & Test (pull_request) Failing after 5m46s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
- Add 1920x1080 HD canvas with trans-pride themed design
- Include profile avatar, name, bio, and lifetime stats
- Show achievement progress with visual progress bar
- Use Tauri fs plugin for cross-platform file reading
- Add scoped file permissions for read/write access
2026-01-25 20:30:47 -08:00
hikari 42c3b4ee83 feat: add persistent lifetime stats and sync achievements
- Add lifetime stats persistence to Rust backend
- Sync achievement state between frontend and backend on startup
- Add commands for loading/saving stats to disk
- Expand achievement definitions with 150+ new achievements
- Fix stats store to properly track total vs session metrics
2026-01-25 20:06:36 -08:00
naomi 8a19f35922 feat: add user profile with avatar, bio, and lifetime stats
- Add profile fields to HikariConfig (name, avatar path, bio)
- Create ProfilePanel component with trans-pride themed styling
- Add profile button to StatusBar for easy access
- Display lifetime stats (messages, tokens, code blocks, files, cost)
- Show achievement completion progress bar
- Support file picker for avatar image selection
- Remove global stats from StatsDisplay (now in Profile)

Co-Authored-By: Hikari <hikari@nhcarrigan.com>
2026-01-25 20:06:03 -08:00
hikari 4f02747064 feat: add ~100 new achievements for extended gameplay
Add extensive new achievement system covering:
- Extended token/code/file/message milestones
- Tool mastery achievements (Edit, Write, Glob, Task, WebFetch, MCP)
- Daily streaks (week, two-week, month, quarter)
- Time-based achievements (morning, night, lunch, coffee break)
- Day-specific achievements (Monday, Wednesday, Friday, weekend)
- Special day achievements (New Year, Valentine's, Halloween, Christmas, Leap Day)
- Message content achievements (long messages, markdown, code blocks)
- Emotional achievements (frustrated, excited, confused, curious, impressed)
- Programming language achievements (Rust, Python, JS, TS, Go, C++, Java, etc.)
- Project type achievements (frontend, backend, config, docs)
- Refactoring/testing/documentation achievements
- Completion percentage achievements (50%, 75%, 100%)

Also adds streak tracking infrastructure to stats:
- Session counting
- Consecutive day tracking
- Morning/night session tracking
- Total days used tracking
2026-01-25 19:22:49 -08:00
hikari 43f92f227c style: fix lint errors and format issues
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 52s
CI / Lint & Test (pull_request) Successful in 14m33s
CI / Build Linux (pull_request) Successful in 18m56s
CI / Build Windows (cross-compile) (pull_request) Successful in 28m46s
- Remove unused configStore import from CompactMode.svelte
- Add key to each block in CompactMode.svelte
- Fix useless escape characters in config.ts path masking
- Apply Prettier formatting to StatusBar, Terminal, and +page.svelte
2026-01-25 19:09:03 -08:00
hikari c5e0d5302c feat: add compact mode for minimal widget interface (#36)
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 54s
CI / Lint & Test (pull_request) Failing after 5m38s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
Add a compact mode that shrinks the window to a small widget showing
just the character sprite, recent messages, and a quick input box.
Perfect for quick questions while working without the full UI.

- Add CompactMode.svelte component with minimal widget interface
- Add compact mode toggle in StatusBar (Ctrl+Shift+M shortcut)
- Save/restore window size when toggling compact mode
- Handle display scaling by converting physical to logical pixels
- Add compact_mode to config (Rust + TypeScript)
- Add required Tauri window permissions for resize operations
2026-01-25 18:51:39 -08:00
hikari ff50b28641 feat: add streamer mode for privacy during streaming
CI / Lint & Test (pull_request) Failing after 5m40s
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 49s
- Add quick toggle button in InputBar for easy access
- Mask API keys in settings when streamer mode active
- Optional path masking to hide usernames in file paths
- Visual LIVE indicator in both InputBar and StatusBar
- Keyboard shortcut Ctrl+Shift+S for quick toggle
- Privacy section in settings for additional options

Closes #35
2026-01-25 18:15:47 -08:00
naomi 0ac52c8c8d style: apply linter formatting and eslint-disable comments
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 55s
CI / Lint & Test (pull_request) Successful in 14m40s
CI / Build Linux (pull_request) Successful in 19m9s
CI / Build Windows (cross-compile) (pull_request) Successful in 28m34s
- Format multiline CSS gradients for readability
- Clean up whitespace in Svelte components
- Add eslint-disable comments for controlled @html directives
- Format function signatures consistently

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 17:41:20 -08:00
naomi 0a73d2238c feat: add clipboard history for code snippets
Implements issue #25 - Clipboard History feature that tracks copied
code snippets with language detection, search, and filtering.

Backend (Rust):
- New clipboard.rs module with persistent storage via tauri-plugin-store
- Commands: capture, list, delete, toggle pin, clear, search, update language
- Auto-deduplication and max history size (100 entries)
- Pinned entries stay at top and persist through clear

Frontend (Svelte/TypeScript):
- Clipboard store with filtering, search, and language detection
- ClipboardHistoryPanel component with search, language filter, pin/delete
- Clipboard button added to InputBar next to Snippets/Actions
- Auto-capture from code block copy buttons
- Auto-capture from manual text selection in terminal
- Insert snippets directly into input field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 17:40:03 -08:00
naomi c34720346e feat: apply trans-pride gradient theme across UI elements
- Add global CSS classes for trans gradient styling (btn-trans-gradient,
  input-trans-focus, icon-trans-hover)
- Update all primary action buttons to use trans gradient
- Add trans-pride glow effect to character panel based on state
- Update StatusBar icon buttons with trans gradient hover
- Update chat input focus border with trans gradient glow
- Update stop button to use trans gradient
2026-01-25 17:07:32 -08:00
hikari 02cc8bd0d5 feat: add quick actions panel for common prompts
- Add Rust backend for managing quick actions with persistent storage
- Create QuickActionsPanel component with edit/delete functionality
- Add quickActions store for frontend state management
- Move Actions and Snippets buttons to input controls row
- Include 6 default quick actions: Review PR, Run Tests, Explain File,
  Fix Error, Write Tests, and Refactor
- Support custom quick action creation and management

Closes #15
2026-01-25 16:25:59 -08:00
hikari 87cf633564 feat: add git integration panel
- Add Rust backend for git operations (status, diff, branches, commit, push, pull, fetch, stage, unstage, discard, create branch)
- Add GitPanel.svelte component with three tabs: Changes, Branches, History
- Integrate git button into StatusBar with link icon
- Use consistent accent color theming for buttons
2026-01-25 16:04:09 -08:00
naomi a30b3a282c feat: add session import/export functionality
- Add HTML export with dark/light theme support and styled message types
- Add PDF export via print-optimized HTML opened in browser
- Include metadata toggle option for all export formats
- Add export buttons to SessionHistoryPanel dropdown

Closes #34
2026-01-25 15:28:06 -08:00
hikari 02987f1a17 feat: add snippet library for prompt templates
- Add Rust backend with persistent storage for snippets
- Include 8 default snippets across categories (Code Review, Debugging, Testing, etc.)
- Create SnippetLibraryPanel component with category filtering
- Support create/edit/delete operations for custom snippets
- Default snippets can be edited but not deleted
- Add snippet button to InputBar for quick access

Closes #22
2026-01-25 15:15:52 -08:00
naomi 4a8a1d564a feat: add session import/export functionality
- Add JSON export with full session metadata for backup/restore
- Add Markdown export with formatted conversation history
- Add import capability for previously exported sessions
- Add tauri-plugin-fs for file system operations
- Add export dropdown menu and import button to session history panel
2026-01-25 15:00:52 -08:00
hikari 89cc655fd1 feat: add copy button to code blocks
Adds a convenient copy-to-clipboard button on all code snippets in
markdown output. Each code block now displays:
- Language label in the header
- Copy button with visual feedback ("Copied!" for 2 seconds)

Refs #68
2026-01-25 14:47:24 -08:00
hikari ce97c51cd8 feat: add session history with auto-save
- Add Rust backend for session persistence using tauri-plugin-store
- Create SessionHistoryPanel component for browsing saved sessions
- Implement debounced auto-save (2s delay) when messages are added
- Add session search, resume, and delete functionality
- Add clock icon button in StatusBar to access session history

Closes #14
2026-01-25 14:10:05 -08:00
hikari b1a45ed00e feat: add high contrast mode for accessibility
- Add HighContrast variant to Theme enum in Rust backend
- Add high-contrast CSS theme with pure black/white for max contrast
- Use bright saturated colors for syntax highlighting
- Add High Contrast button to theme selector with accessibility tooltip
- Follows WCAG guidelines for contrast ratios

Closes #20
2026-01-25 13:44:52 -08:00
hikari 457722dc3a feat: add minimize to system tray option
Add ability to minimize Hikari to the system tray when closing the window
instead of fully exiting. When enabled, clicking the close button hides
the window and shows a tray icon with "Show Hikari" and "Quit" options.

- Add tray module with system tray setup and menu handling
- Add minimize_to_tray config option in settings
- Handle window close event to hide instead of close when enabled
- Add tray icon click handler to restore window
2026-01-25 13:38:30 -08:00
110 changed files with 165 additions and 11912 deletions
+5 -8
View File
@@ -49,13 +49,13 @@ jobs:
- name: Run Svelte Check
run: pnpm check
- name: Run frontend tests with coverage
run: pnpm test:coverage
- name: Run frontend tests
run: pnpm test
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, llvm-tools-preview
components: clippy
- name: Cache Rust dependencies
uses: actions/cache@v4
@@ -68,16 +68,13 @@ jobs:
src-tauri/target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install cargo-llvm-cov
run: cargo install cargo-llvm-cov --locked
- name: Run Clippy
working-directory: src-tauri
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run Rust tests with coverage
- name: Run Rust tests
working-directory: src-tauri
run: cargo llvm-cov --fail-under-lines 50
run: cargo test
build-linux:
name: Build Linux
-3
View File
@@ -8,6 +8,3 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Coverage reports
/coverage
+1 -29
View File
@@ -1,29 +1 @@
# hikari-desktop
Desktop companion application featuring Hikari.
## Live Version
This page is currently deployed. [View the live website.](https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/releases)
## Feedback and Bugs
If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
## Contributing
If you would like to contribute to the project, you may create a Pull Request containing your proposed changes and we will review it as soon as we are able! Please review our [contributing guidelines](CONTRIBUTING.md) first.
## Code of Conduct
Before interacting with our community, please read our [Code of Conduct](CODE_OF_CONDUCT.md).
## License
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
Copyright held by Naomi Carrigan.
## Contact
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`
tem
+74
View File
@@ -0,0 +1,74 @@
# Hikari Desktop v0.3.0 Release Notes
## New Features
### AskUserQuestion Tool Support (#51)
- Claude can now ask you questions with multiple choice options during conversations
- A dedicated modal appears with answer choices and support for custom responses
- Answers are seamlessly integrated back into the conversation context
### Slash Commands
- `/cd <path>` - Change the working directory while preserving conversation context (#55)
- `/search <query>` - Search and highlight matches within the conversation (#32)
- `/skill <name> [data]` - Invoke Claude Code skills from `~/.claude/skills/` (#57)
- `/new`, `/clear`, `/rename`, `/help` commands for conversation management (#6)
### Auto-Update Checker (#17)
- Automatically checks for new releases on startup
- Notification appears when a newer version is available
- Can be disabled in settings
### Font Size & Zoom (#19)
- Adjust font size with keyboard shortcuts: `Ctrl++`, `Ctrl+-`, `Ctrl+0`
- Font size slider in settings (10-24px range)
- Preference persists between sessions
### Resizable Character Panel (#10)
- Drag the divider between the character panel and terminal to resize
- Panel width is saved and restored on app restart
- Sprite now uses full height for better proportions
### Input History Navigation (#13)
- Use up/down arrows to navigate through previous messages and commands
- Arrow keys only navigate history when input is empty - otherwise they move the cursor (#58)
### Keyboard Shortcuts (#21)
- `Ctrl+N` - New conversation
- `Ctrl+W` - Close current tab
- `Ctrl+Tab` / `Ctrl+Shift+Tab` - Switch between tabs
- `Ctrl+L` - Clear conversation
- `Ctrl+,` - Open settings
### Always On Top Toggle (#28)
- Pin the window to stay above other applications
- Toggle in settings
## Improvements
### UI/UX
- Resizable chat input with drag handle (expands upward)
- Send button properly aligned with input field
- Markdown rendering with syntax-highlighted code blocks (#31, #33)
- Light mode text colors improved for better readability
- Scroll position persists per conversation tab when switching
- Confirmation modal when closing connected tabs
- Links in chat now open in default browser (#54)
- Spaces allowed when renaming tabs (#52)
### State Management
- Stats (tokens, cost) persist across session changes and only reset on disconnect (#59)
- Multiple tabs can now request permissions simultaneously without conflicts
## Closed Issues
#6, #10, #13, #17, #19, #21, #28, #31, #32, #33, #51, #52, #54, #55, #57, #58, #59
+1 -1
View File
@@ -27,6 +27,6 @@ export default tseslint.config(
},
},
{
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/", "coverage/"],
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/"],
}
);
+1 -33
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.1.0",
"version": "0.3.0",
"description": "",
"type": "module",
"scripts": {
@@ -16,10 +16,6 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:backend": "cd src-tauri && cargo test",
"test:backend:coverage": "cd src-tauri && cargo llvm-cov --text",
"test:all": "pnpm test && pnpm test:backend",
"coverage:all": "pnpm test:coverage && pnpm test:backend:coverage",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
@@ -27,32 +23,6 @@
},
"license": "MIT",
"dependencies": {
"@codemirror/commands": "6.8.1",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.11",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2",
@@ -62,7 +32,6 @@
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2",
"codemirror": "^6.0.2",
"highlight.js": "^11.11.1",
"marked": "^17.0.1"
},
@@ -75,7 +44,6 @@
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
-684
View File
@@ -8,84 +8,6 @@ importers:
.:
dependencies:
'@codemirror/commands':
specifier: 6.8.1
version: 6.8.1
'@codemirror/lang-angular':
specifier: ^0.1.4
version: 0.1.4
'@codemirror/lang-cpp':
specifier: ^6.0.3
version: 6.0.3
'@codemirror/lang-css':
specifier: ^6.3.1
version: 6.3.1
'@codemirror/lang-go':
specifier: ^6.0.1
version: 6.0.1
'@codemirror/lang-html':
specifier: ^6.4.11
version: 6.4.11
'@codemirror/lang-java':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-javascript':
specifier: ^6.2.4
version: 6.2.4
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-less':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-markdown':
specifier: ^6.5.0
version: 6.5.0
'@codemirror/lang-php':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-python':
specifier: ^6.2.1
version: 6.2.1
'@codemirror/lang-rust':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-sass':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-sql':
specifier: ^6.10.0
version: 6.10.0
'@codemirror/lang-vue':
specifier: ^0.1.3
version: 0.1.3
'@codemirror/lang-wast':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-xml':
specifier: ^6.1.0
version: 6.1.0
'@codemirror/lang-yaml':
specifier: ^6.1.2
version: 6.1.2
'@codemirror/language':
specifier: ^6.12.1
version: 6.12.1
'@codemirror/legacy-modes':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/state':
specifier: ^6.5.4
version: 6.5.4
'@codemirror/theme-one-dark':
specifier: ^6.1.3
version: 6.1.3
'@codemirror/view':
specifier: ^6.39.11
version: 6.39.11
'@lezer/highlight':
specifier: ^1.2.3
version: 1.2.3
'@tauri-apps/api':
specifier: ^2
version: 2.9.1
@@ -113,9 +35,6 @@ importers:
'@tauri-apps/plugin-store':
specifier: ^2
version: 2.4.2
codemirror:
specifier: ^6.0.2
version: 6.0.2
highlight.js:
specifier: ^11.11.1
version: 11.11.1
@@ -147,9 +66,6 @@ importers:
'@testing-library/svelte':
specifier: ^5.3.1
version: 5.3.1(svelte@5.46.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))
'@vitest/coverage-v8':
specifier: ^4.0.18
version: 4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))
eslint:
specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1)
@@ -214,115 +130,14 @@ packages:
resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.28.6':
resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.6':
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@codemirror/autocomplete@6.20.0':
resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-angular@0.1.4':
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
'@codemirror/lang-cpp@6.0.3':
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-go@6.0.1':
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
'@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-java@6.0.2':
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
'@codemirror/lang-javascript@6.2.4':
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-less@6.0.2':
resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
'@codemirror/lang-markdown@6.5.0':
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
'@codemirror/lang-php@6.0.2':
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
'@codemirror/lang-python@6.2.1':
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
'@codemirror/lang-rust@6.0.2':
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
'@codemirror/lang-sass@6.0.2':
resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
'@codemirror/lang-sql@6.10.0':
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
'@codemirror/lang-vue@0.1.3':
resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
'@codemirror/lang-wast@6.0.2':
resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
'@codemirror/lang-xml@6.1.0':
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
'@codemirror/lang-yaml@6.1.2':
resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
'@codemirror/language@6.12.1':
resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
'@codemirror/legacy-modes@6.5.2':
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
'@codemirror/lint@6.9.3':
resolution: {integrity: sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==}
'@codemirror/search@6.6.0':
resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
'@codemirror/state@6.5.4':
resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.39.11':
resolution: {integrity: sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'}
@@ -590,60 +405,6 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.0':
resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==}
'@lezer/cpp@1.1.5':
resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==}
'@lezer/css@1.3.0':
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
'@lezer/go@1.0.1':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.13':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
'@lezer/java@1.1.3':
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
'@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.8':
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
'@lezer/markdown@1.6.3':
resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
'@lezer/php@1.0.5':
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
'@lezer/python@1.1.18':
resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
'@lezer/rust@1.0.2':
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
'@lezer/sass@1.1.0':
resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
'@lezer/xml@1.0.6':
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
'@lezer/yaml@1.0.3':
resolution: {integrity: sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -1108,15 +869,6 @@ packages:
resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/coverage-v8@4.0.18':
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
peerDependencies:
'@vitest/browser': 4.0.18
vitest: 4.0.18
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.0.17':
resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==}
@@ -1134,9 +886,6 @@ packages:
'@vitest/pretty-format@4.0.17':
resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==}
'@vitest/pretty-format@4.0.18':
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
'@vitest/runner@4.0.17':
resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==}
@@ -1149,9 +898,6 @@ packages:
'@vitest/utils@4.0.17':
resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==}
'@vitest/utils@4.0.18':
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1195,9 +941,6 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-v8-to-istanbul@0.3.10:
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -1234,9 +977,6 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1251,9 +991,6 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1474,9 +1211,6 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -1522,18 +1256,6 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -1541,9 +1263,6 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@@ -1675,13 +1394,6 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.1:
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
@@ -1888,9 +1600,6 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2070,9 +1779,6 @@ packages:
jsdom:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -2163,233 +1869,10 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.28.6':
dependencies:
'@babel/types': 7.28.6
'@babel/runtime@7.28.6': {}
'@babel/types@7.28.6':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@codemirror/autocomplete@6.20.0':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@codemirror/commands@6.8.1':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@codemirror/lang-angular@0.1.4':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-cpp@6.0.3':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/cpp': 1.1.5
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/css': 1.3.0
'@codemirror/lang-go@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/go': 1.0.1
'@codemirror/lang-html@6.4.11':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@lezer/css': 1.3.0
'@lezer/html': 1.3.13
'@codemirror/lang-java@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/java': 1.1.3
'@codemirror/lang-javascript@6.2.4':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/lint': 6.9.3
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@lezer/javascript': 1.5.4
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/json': 1.0.3
'@codemirror/lang-less@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-markdown@6.5.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@lezer/markdown': 1.6.3
'@codemirror/lang-php@6.0.2':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/php': 1.0.5
'@codemirror/lang-python@6.2.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/python': 1.1.18
'@codemirror/lang-rust@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/rust': 1.0.2
'@codemirror/lang-sass@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/sass': 1.1.0
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-vue@0.1.3':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-wast@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-xml@6.1.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@lezer/xml': 1.0.6
'@codemirror/lang-yaml@6.1.2':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/yaml': 1.0.3
'@codemirror/language@6.12.1':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
style-mod: 4.1.3
'@codemirror/legacy-modes@6.5.2':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/lint@6.9.3':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
crelt: 1.0.6
'@codemirror/search@6.6.0':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
crelt: 1.0.6
'@codemirror/state@6.5.4':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/highlight': 1.2.3
'@codemirror/view@6.39.11':
dependencies:
'@codemirror/state': 6.5.4
crelt: 1.0.6
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@csstools/color-helpers@5.1.0': {}
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
@@ -2568,101 +2051,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lezer/common@1.5.0': {}
'@lezer/cpp@1.1.5':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/css@1.3.0':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/go@1.0.1':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.0
'@lezer/html@1.3.13':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/java@1.1.3':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/javascript@1.5.4':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/lr@1.4.8':
dependencies:
'@lezer/common': 1.5.0
'@lezer/markdown@1.6.3':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/php@1.0.5':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/python@1.1.18':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/rust@1.0.2':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/sass@1.1.0':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/xml@1.0.6':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/yaml@1.0.3':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@marijn/find-cluster-break@1.0.2': {}
'@polka/url@1.0.0-next.29': {}
'@rollup/rollup-android-arm-eabi@4.55.1':
@@ -3081,20 +2469,6 @@ snapshots:
'@typescript-eslint/types': 8.53.0
eslint-visitor-keys: 4.2.1
'@vitest/coverage-v8@4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18
ast-v8-to-istanbul: 0.3.10
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.1
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)
'@vitest/expect@4.0.17':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -3116,10 +2490,6 @@ snapshots:
dependencies:
tinyrainbow: 3.0.3
'@vitest/pretty-format@4.0.18':
dependencies:
tinyrainbow: 3.0.3
'@vitest/runner@4.0.17':
dependencies:
'@vitest/utils': 4.0.17
@@ -3138,11 +2508,6 @@ snapshots:
'@vitest/pretty-format': 4.0.17
tinyrainbow: 3.0.3
'@vitest/utils@4.0.18':
dependencies:
'@vitest/pretty-format': 4.0.18
tinyrainbow: 3.0.3
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -3176,12 +2541,6 @@ snapshots:
assertion-error@2.0.1: {}
ast-v8-to-istanbul@0.3.10:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 9.0.1
axobject-query@4.1.0: {}
balanced-match@1.0.2: {}
@@ -3214,16 +2573,6 @@ snapshots:
clsx@2.1.1: {}
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.8.1
'@codemirror/language': 6.12.1
'@codemirror/lint': 6.9.3
'@codemirror/search': 6.6.0
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -3234,8 +2583,6 @@ snapshots:
cookie@0.6.0: {}
crelt@1.0.6: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -3476,8 +2823,6 @@ snapshots:
transitivePeerDependencies:
- '@exodus/crypto'
html-escaper@2.0.2: {}
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -3519,25 +2864,10 @@ snapshots:
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jiti@2.6.1: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -3656,16 +2986,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.1:
dependencies:
'@babel/parser': 7.28.6
'@babel/types': 7.28.6
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.3
marked@17.0.1: {}
mdn-data@2.12.2: {}
@@ -3852,8 +3172,6 @@ snapshots:
strip-json-comments@3.1.1: {}
style-mod@4.1.3: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -4013,8 +3331,6 @@ snapshots:
- tsx
- yaml
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
+1 -1
View File
@@ -1602,7 +1602,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hikari-desktop"
version = "1.1.0"
version = "0.3.0"
dependencies = [
"chrono",
"parking_lot",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.1.0"
version = "0.3.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because it is too large Load Diff
-465
View File
@@ -257,468 +257,3 @@ pub fn update_clipboard_language(
save_history(&app, &history)?;
Ok(updated_entry)
}
#[cfg(test)]
mod tests {
use super::*;
// ==================== ClipboardEntry tests ====================
#[test]
fn test_clipboard_entry_new() {
let entry = ClipboardEntry::new(
"let x = 42;".to_string(),
Some("rust".to_string()),
Some("main.rs".to_string()),
);
assert_eq!(entry.content, "let x = 42;");
assert_eq!(entry.language, Some("rust".to_string()));
assert_eq!(entry.source, Some("main.rs".to_string()));
assert!(!entry.is_pinned);
assert!(!entry.id.is_empty());
assert!(!entry.timestamp.is_empty());
}
#[test]
fn test_clipboard_entry_new_without_optional_fields() {
let entry = ClipboardEntry::new("some content".to_string(), None, None);
assert_eq!(entry.content, "some content");
assert!(entry.language.is_none());
assert!(entry.source.is_none());
assert!(!entry.is_pinned);
}
#[test]
fn test_clipboard_entry_unique_ids() {
let entry1 = ClipboardEntry::new("content1".to_string(), None, None);
let entry2 = ClipboardEntry::new("content2".to_string(), None, None);
assert_ne!(entry1.id, entry2.id);
}
#[test]
fn test_clipboard_entry_serialization() {
let entry = ClipboardEntry::new(
"fn main() {}".to_string(),
Some("rust".to_string()),
Some("lib.rs".to_string()),
);
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("fn main() {}"));
assert!(json.contains("rust"));
assert!(json.contains("lib.rs"));
assert!(json.contains("is_pinned"));
let deserialized: ClipboardEntry = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.content, entry.content);
assert_eq!(deserialized.language, entry.language);
assert_eq!(deserialized.source, entry.source);
assert_eq!(deserialized.id, entry.id);
}
#[test]
fn test_clipboard_entry_clone() {
let entry = ClipboardEntry::new(
"original".to_string(),
Some("python".to_string()),
None,
);
let cloned = entry.clone();
assert_eq!(cloned.content, entry.content);
assert_eq!(cloned.id, entry.id);
assert_eq!(cloned.language, entry.language);
}
#[test]
fn test_clipboard_entry_timestamp_is_rfc3339() {
let entry = ClipboardEntry::new("test".to_string(), None, None);
// RFC3339 timestamp should parse successfully
let parsed = chrono::DateTime::parse_from_rfc3339(&entry.timestamp);
assert!(parsed.is_ok());
}
// ==================== ClipboardHistory tests ====================
#[test]
fn test_clipboard_history_default() {
let history = ClipboardHistory::default();
assert!(history.entries.is_empty());
}
#[test]
fn test_clipboard_history_serialization() {
let mut history = ClipboardHistory::default();
history.entries.push(ClipboardEntry::new(
"entry1".to_string(),
Some("js".to_string()),
None,
));
history.entries.push(ClipboardEntry::new(
"entry2".to_string(),
None,
Some("file.txt".to_string()),
));
let json = serde_json::to_string(&history).unwrap();
assert!(json.contains("entry1"));
assert!(json.contains("entry2"));
assert!(json.contains("js"));
assert!(json.contains("file.txt"));
let deserialized: ClipboardHistory = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.entries.len(), 2);
}
#[test]
fn test_clipboard_history_entries_order() {
let mut history = ClipboardHistory::default();
history.entries.push(ClipboardEntry::new("first".to_string(), None, None));
history.entries.push(ClipboardEntry::new("second".to_string(), None, None));
history.entries.push(ClipboardEntry::new("third".to_string(), None, None));
assert_eq!(history.entries[0].content, "first");
assert_eq!(history.entries[1].content, "second");
assert_eq!(history.entries[2].content, "third");
}
// ==================== ClipboardState tests ====================
#[test]
fn test_clipboard_state_default() {
let state = ClipboardState::default();
assert!(state.last_content.is_none());
}
#[test]
fn test_clipboard_state_with_content() {
let state = ClipboardState {
last_content: Some("cached content".to_string()),
};
assert_eq!(state.last_content, Some("cached content".to_string()));
}
// ==================== MAX_HISTORY_SIZE constant test ====================
#[test]
fn test_max_history_size_is_reasonable() {
assert_eq!(MAX_HISTORY_SIZE, 100);
// Compile-time assertions for constant bounds
const _: () = assert!(MAX_HISTORY_SIZE > 0);
const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
}
// ==================== Pinned entry sorting tests ====================
#[test]
#[allow(clippy::useless_vec)]
fn test_pinned_entries_sorting() {
let mut entries = vec![
ClipboardEntry {
id: "1".to_string(),
content: "unpinned older".to_string(),
language: None,
source: None,
timestamp: "2024-01-01T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "2".to_string(),
content: "pinned".to_string(),
language: None,
source: None,
timestamp: "2024-01-02T00:00:00Z".to_string(),
is_pinned: true,
},
ClipboardEntry {
id: "3".to_string(),
content: "unpinned newer".to_string(),
language: None,
source: None,
timestamp: "2024-01-03T00:00:00Z".to_string(),
is_pinned: false,
},
];
// Apply the same sorting logic as used in the module
entries.sort_by(|a, b| {
if a.is_pinned && !b.is_pinned {
std::cmp::Ordering::Less
} else if !a.is_pinned && b.is_pinned {
std::cmp::Ordering::Greater
} else {
b.timestamp.cmp(&a.timestamp)
}
});
// Pinned should be first
assert!(entries[0].is_pinned);
assert_eq!(entries[0].id, "2");
// Then unpinned sorted by timestamp descending (newest first)
assert_eq!(entries[1].id, "3"); // newer unpinned
assert_eq!(entries[2].id, "1"); // older unpinned
}
#[test]
#[allow(clippy::useless_vec)]
fn test_multiple_pinned_entries_sorting() {
let mut entries = vec![
ClipboardEntry {
id: "1".to_string(),
content: "pinned older".to_string(),
language: None,
source: None,
timestamp: "2024-01-01T00:00:00Z".to_string(),
is_pinned: true,
},
ClipboardEntry {
id: "2".to_string(),
content: "unpinned".to_string(),
language: None,
source: None,
timestamp: "2024-01-02T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "3".to_string(),
content: "pinned newer".to_string(),
language: None,
source: None,
timestamp: "2024-01-03T00:00:00Z".to_string(),
is_pinned: true,
},
];
entries.sort_by(|a, b| {
if a.is_pinned && !b.is_pinned {
std::cmp::Ordering::Less
} else if !a.is_pinned && b.is_pinned {
std::cmp::Ordering::Greater
} else {
b.timestamp.cmp(&a.timestamp)
}
});
// Both pinned first, sorted by timestamp
assert!(entries[0].is_pinned);
assert_eq!(entries[0].id, "3"); // pinned newer
assert!(entries[1].is_pinned);
assert_eq!(entries[1].id, "1"); // pinned older
// Then unpinned
assert!(!entries[2].is_pinned);
assert_eq!(entries[2].id, "2");
}
// ==================== Entry filtering tests ====================
#[test]
fn test_filter_entries_by_language() {
let history = ClipboardHistory {
entries: vec![
ClipboardEntry {
id: "1".to_string(),
content: "rust code".to_string(),
language: Some("rust".to_string()),
source: None,
timestamp: "2024-01-01T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "2".to_string(),
content: "js code".to_string(),
language: Some("javascript".to_string()),
source: None,
timestamp: "2024-01-02T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "3".to_string(),
content: "more rust".to_string(),
language: Some("rust".to_string()),
source: None,
timestamp: "2024-01-03T00:00:00Z".to_string(),
is_pinned: false,
},
],
};
let filtered: Vec<_> = history
.entries
.iter()
.filter(|e| e.language.as_ref() == Some(&"rust".to_string()))
.collect();
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|e| e.language == Some("rust".to_string())));
}
#[test]
fn test_search_entries_by_content() {
let history = ClipboardHistory {
entries: vec![
ClipboardEntry {
id: "1".to_string(),
content: "fn hello_world()".to_string(),
language: Some("rust".to_string()),
source: None,
timestamp: "2024-01-01T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "2".to_string(),
content: "function hello()".to_string(),
language: Some("javascript".to_string()),
source: None,
timestamp: "2024-01-02T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "3".to_string(),
content: "def goodbye()".to_string(),
language: Some("python".to_string()),
source: None,
timestamp: "2024-01-03T00:00:00Z".to_string(),
is_pinned: false,
},
],
};
let query = "hello";
let query_lower = query.to_lowercase();
let filtered: Vec<_> = history
.entries
.iter()
.filter(|e| e.content.to_lowercase().contains(&query_lower))
.collect();
assert_eq!(filtered.len(), 2);
assert!(filtered[0].content.contains("hello"));
assert!(filtered[1].content.contains("hello"));
}
#[test]
fn test_search_entries_case_insensitive() {
let history = ClipboardHistory {
entries: vec![
ClipboardEntry {
id: "1".to_string(),
content: "HELLO WORLD".to_string(),
language: None,
source: None,
timestamp: "2024-01-01T00:00:00Z".to_string(),
is_pinned: false,
},
],
};
let query = "hello";
let query_lower = query.to_lowercase();
let filtered: Vec<_> = history
.entries
.iter()
.filter(|e| e.content.to_lowercase().contains(&query_lower))
.collect();
assert_eq!(filtered.len(), 1);
}
// ==================== Unique languages extraction test ====================
#[test]
fn test_extract_unique_languages() {
let history = ClipboardHistory {
entries: vec![
ClipboardEntry {
id: "1".to_string(),
content: "".to_string(),
language: Some("rust".to_string()),
source: None,
timestamp: "".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "2".to_string(),
content: "".to_string(),
language: Some("javascript".to_string()),
source: None,
timestamp: "".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "3".to_string(),
content: "".to_string(),
language: Some("rust".to_string()), // Duplicate
source: None,
timestamp: "".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "4".to_string(),
content: "".to_string(),
language: None, // No language
source: None,
timestamp: "".to_string(),
is_pinned: false,
},
],
};
let mut languages: Vec<String> = history
.entries
.iter()
.filter_map(|e| e.language.clone())
.collect();
languages.sort();
languages.dedup();
assert_eq!(languages.len(), 2);
assert!(languages.contains(&"rust".to_string()));
assert!(languages.contains(&"javascript".to_string()));
}
// ==================== Retain pinned entries test ====================
#[test]
fn test_retain_pinned_on_clear() {
let mut history = ClipboardHistory {
entries: vec![
ClipboardEntry {
id: "1".to_string(),
content: "pinned".to_string(),
language: None,
source: None,
timestamp: "".to_string(),
is_pinned: true,
},
ClipboardEntry {
id: "2".to_string(),
content: "unpinned".to_string(),
language: None,
source: None,
timestamp: "".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "3".to_string(),
content: "another pinned".to_string(),
language: None,
source: None,
timestamp: "".to_string(),
is_pinned: true,
},
],
};
// Simulate clear (keep only pinned)
history.entries.retain(|e| e.is_pinned);
assert_eq!(history.entries.len(), 2);
assert!(history.entries.iter().all(|e| e.is_pinned));
}
}
-422
View File
@@ -393,425 +393,3 @@ pub async fn get_file_size(file_path: String) -> Result<u64, String> {
.map_err(|e| format!("Failed to get file metadata: {}", e))?;
Ok(metadata.len())
}
// ==================== Editor File Operations ====================
#[derive(Debug, Clone, serde::Serialize)]
pub struct FileEntry {
pub name: String,
pub path: String,
#[serde(rename = "isDirectory")]
pub is_directory: bool,
}
#[tauri::command]
pub async fn list_directory(path: String) -> Result<Vec<FileEntry>, String> {
use std::fs;
use std::path::Path;
let dir_path = Path::new(&path);
if !dir_path.exists() {
return Err(format!("Directory does not exist: {}", path));
}
if !dir_path.is_dir() {
return Err(format!("Path is not a directory: {}", path));
}
let entries = fs::read_dir(dir_path)
.map_err(|e| format!("Failed to read directory: {}", e))?;
let mut file_entries = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
let name = entry
.file_name()
.to_string_lossy()
.to_string();
// Skip hidden files by default (can be made configurable later)
if name.starts_with('.') {
continue;
}
file_entries.push(FileEntry {
name,
path: path.to_string_lossy().to_string(),
is_directory: path.is_dir(),
});
}
Ok(file_entries)
}
#[tauri::command]
pub async fn read_file_content(path: String) -> Result<String, String> {
use std::fs;
fs::read_to_string(&path)
.map_err(|e| format!("Failed to read file: {}", e))
}
#[tauri::command]
pub async fn write_file_content(path: String, content: String) -> Result<(), String> {
use std::fs;
fs::write(&path, content)
.map_err(|e| format!("Failed to write file: {}", e))
}
#[tauri::command]
pub async fn create_file(path: String) -> Result<(), String> {
use std::fs::File;
use std::path::Path;
let file_path = Path::new(&path);
if file_path.exists() {
return Err("File already exists".to_string());
}
File::create(file_path).map_err(|e| format!("Failed to create file: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn create_directory(path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let dir_path = Path::new(&path);
if dir_path.exists() {
return Err("Directory already exists".to_string());
}
fs::create_dir_all(dir_path).map_err(|e| format!("Failed to create directory: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn delete_file(path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let file_path = Path::new(&path);
if !file_path.exists() {
return Err("File does not exist".to_string());
}
if file_path.is_dir() {
return Err("Path is a directory, use delete_directory instead".to_string());
}
fs::remove_file(file_path).map_err(|e| format!("Failed to delete file: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn delete_directory(path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let dir_path = Path::new(&path);
if !dir_path.exists() {
return Err("Directory does not exist".to_string());
}
if !dir_path.is_dir() {
return Err("Path is not a directory".to_string());
}
fs::remove_dir_all(dir_path).map_err(|e| format!("Failed to delete directory: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn rename_path(old_path: String, new_path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let old = Path::new(&old_path);
let new = Path::new(&new_path);
if !old.exists() {
return Err("Path does not exist".to_string());
}
if new.exists() {
return Err("Destination already exists".to_string());
}
fs::rename(old, new).map_err(|e| format!("Failed to rename: {}", e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::io::Write;
use tempfile::TempDir;
// Helper to run async tests
fn run_async<F: std::future::Future>(f: F) -> F::Output {
tokio::runtime::Runtime::new().unwrap().block_on(f)
}
// ==================== validate_directory tests ====================
#[test]
fn test_validate_directory_absolute_path_exists() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().to_string_lossy().to_string();
let result = run_async(validate_directory(path.clone(), None));
assert!(result.is_ok());
// Canonicalized path should be returned
assert!(result.unwrap().contains(&temp_dir.path().file_name().unwrap().to_string_lossy().to_string()));
}
#[test]
fn test_validate_directory_path_not_exists() {
let result = run_async(validate_directory(
"/nonexistent/path/that/does/not/exist".to_string(),
None,
));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_validate_directory_path_is_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file.txt");
File::create(&file_path).unwrap();
let result = run_async(validate_directory(
file_path.to_string_lossy().to_string(),
None,
));
assert!(result.is_err());
assert!(result.unwrap_err().contains("not a directory"));
}
#[test]
fn test_validate_directory_home_expansion() {
// This test assumes HOME is set (which it should be on most systems)
if std::env::var_os("HOME").is_some() {
let result = run_async(validate_directory("~".to_string(), None));
assert!(result.is_ok());
// Should not contain ~ after expansion
assert!(!result.unwrap().contains("~"));
}
}
#[test]
fn test_validate_directory_home_subpath_expansion() {
// This test assumes HOME is set and has some subdirectory
if let Some(home) = std::env::var_os("HOME") {
let home_path = std::path::Path::new(&home);
// Find any subdirectory in home
if let Ok(entries) = fs::read_dir(home_path) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let subdir_name = entry.file_name().to_string_lossy().to_string();
let tilde_path = format!("~/{}", subdir_name);
let result = run_async(validate_directory(tilde_path, None));
assert!(result.is_ok());
assert!(!result.unwrap().contains("~"));
break;
}
}
}
}
}
#[test]
fn test_validate_directory_relative_path_with_current_dir() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let result = run_async(validate_directory(
"subdir".to_string(),
Some(temp_dir.path().to_string_lossy().to_string()),
));
assert!(result.is_ok());
assert!(result.unwrap().contains("subdir"));
}
#[test]
fn test_validate_directory_dot_path() {
let temp_dir = TempDir::new().unwrap();
let result = run_async(validate_directory(
".".to_string(),
Some(temp_dir.path().to_string_lossy().to_string()),
));
assert!(result.is_ok());
}
#[test]
fn test_validate_directory_dotdot_path() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let result = run_async(validate_directory(
"..".to_string(),
Some(subdir.to_string_lossy().to_string()),
));
assert!(result.is_ok());
// Should resolve to parent
let resolved = result.unwrap();
assert!(resolved.contains(&temp_dir.path().file_name().unwrap().to_string_lossy().to_string()));
}
#[test]
fn test_validate_directory_relative_without_current_dir() {
// Relative path without current_dir - should fail since relative path likely won't exist
let result = run_async(validate_directory(
"some_random_nonexistent_relative_path".to_string(),
None,
));
assert!(result.is_err());
}
// ==================== get_file_size tests ====================
#[test]
fn test_get_file_size_empty_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("empty.txt");
File::create(&file_path).unwrap();
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[test]
fn test_get_file_size_with_content() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("content.txt");
let mut file = File::create(&file_path).unwrap();
file.write_all(b"Hello, Hikari!").unwrap();
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
assert!(result.is_ok());
assert_eq!(result.unwrap(), 14); // "Hello, Hikari!" is 14 bytes
}
#[test]
fn test_get_file_size_larger_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("large.txt");
let mut file = File::create(&file_path).unwrap();
// Write 1000 bytes
let data = vec![b'x'; 1000];
file.write_all(&data).unwrap();
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
assert!(result.is_ok());
assert_eq!(result.unwrap(), 1000);
}
#[test]
fn test_get_file_size_nonexistent_file() {
let result = run_async(get_file_size(
"/nonexistent/path/file.txt".to_string(),
));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to get file metadata"));
}
#[test]
fn test_get_file_size_directory() {
let temp_dir = TempDir::new().unwrap();
// Getting "size" of a directory should work but return directory metadata
// This is actually valid - directories have metadata too
let result = run_async(get_file_size(temp_dir.path().to_string_lossy().to_string()));
assert!(result.is_ok());
// Directory size is platform-dependent, just check it returns something
}
// ==================== list_skills tests ====================
#[test]
fn test_list_skills_no_skills_dir() {
// This test is tricky because it depends on HOME being set
// and potentially affecting real user data, so we'll just
// verify the function doesn't panic
let result = run_async(list_skills());
// Should either return Ok with a list or Ok with empty vec
assert!(result.is_ok());
}
// ==================== select_wsl_directory tests ====================
#[test]
fn test_select_wsl_directory_returns_home() {
let result = run_async(select_wsl_directory());
assert!(result.is_ok());
assert_eq!(result.unwrap(), "/home");
}
// ==================== UpdateInfo struct tests ====================
#[test]
fn test_update_info_serialization() {
let info = UpdateInfo {
current_version: "1.0.0".to_string(),
latest_version: "0.4.0".to_string(),
has_update: true,
release_url: "https://example.com/release".to_string(),
release_notes: Some("New features!".to_string()),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("1.0.0"));
assert!(json.contains("0.4.0"));
assert!(json.contains("true"));
assert!(json.contains("New features!"));
}
#[test]
fn test_update_info_without_notes() {
let info = UpdateInfo {
current_version: "1.0.0".to_string(),
latest_version: "1.0.0".to_string(),
has_update: false,
release_url: "https://example.com/release".to_string(),
release_notes: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("null") || json.contains("release_notes"));
}
// ==================== SavedFileInfo struct tests ====================
#[test]
fn test_saved_file_info_serialization() {
let info = SavedFileInfo {
path: "/tmp/test.txt".to_string(),
filename: "test.txt".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("/tmp/test.txt"));
assert!(json.contains("test.txt"));
}
}
-31
View File
@@ -92,10 +92,6 @@ pub struct HikariConfig {
#[serde(default)]
pub profile_bio: Option<String>,
// Custom theme colors
#[serde(default)]
pub custom_theme_colors: CustomThemeColors,
}
impl Default for HikariConfig {
@@ -122,7 +118,6 @@ impl Default for HikariConfig {
profile_name: None,
profile_avatar_path: None,
profile_bio: None,
custom_theme_colors: CustomThemeColors::default(),
}
}
}
@@ -155,27 +150,6 @@ pub enum Theme {
Light,
#[serde(rename = "high-contrast")]
HighContrast,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct CustomThemeColors {
#[serde(default)]
pub bg_primary: Option<String>,
#[serde(default)]
pub bg_secondary: Option<String>,
#[serde(default)]
pub bg_terminal: Option<String>,
#[serde(default)]
pub accent_primary: Option<String>,
#[serde(default)]
pub accent_secondary: Option<String>,
#[serde(default)]
pub text_primary: Option<String>,
#[serde(default)]
pub text_secondary: Option<String>,
#[serde(default)]
pub border_color: Option<String>,
}
#[cfg(test)]
@@ -204,7 +178,6 @@ mod tests {
assert!(config.profile_name.is_none());
assert!(config.profile_avatar_path.is_none());
assert!(config.profile_bio.is_none());
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
}
#[test]
@@ -231,7 +204,6 @@ mod tests {
profile_name: Some("Test User".to_string()),
profile_avatar_path: None,
profile_bio: Some("A test bio".to_string()),
custom_theme_colors: CustomThemeColors::default(),
};
let json = serde_json::to_string(&config).unwrap();
@@ -260,8 +232,5 @@ mod tests {
serde_json::to_string(&high_contrast).unwrap(),
"\"high-contrast\""
);
let custom = Theme::Custom;
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
}
}
-590
View File
@@ -286,593 +286,3 @@ pub fn git_discard(working_dir: String, file_path: String) -> Result<String, Str
pub fn git_create_branch(working_dir: String, branch_name: String) -> Result<String, String> {
run_git_command(&working_dir, &["checkout", "-b", &branch_name])
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::io::Write;
use tempfile::TempDir;
// Helper to create a git repository in a temp directory
fn create_test_repo() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Initialize git repo
run_git_command(&working_dir, &["init"]).unwrap();
// Configure git user for commits
run_git_command(&working_dir, &["config", "user.email", "test@example.com"]).unwrap();
run_git_command(&working_dir, &["config", "user.name", "Test User"]).unwrap();
// Disable GPG signing for tests (user may have it enabled globally)
run_git_command(&working_dir, &["config", "commit.gpgsign", "false"]).unwrap();
temp_dir
}
// Helper to create a file in the test repo
fn create_file(dir: &TempDir, name: &str, content: &str) {
let file_path = dir.path().join(name);
let mut file = File::create(file_path).unwrap();
file.write_all(content.as_bytes()).unwrap();
}
// ==================== GitStatus struct tests ====================
#[test]
fn test_git_status_serialization() {
let status = GitStatus {
is_repo: true,
branch: Some("main".to_string()),
upstream: Some("origin/main".to_string()),
ahead: 2,
behind: 1,
staged: vec![GitFileChange {
path: "file.txt".to_string(),
status: "modified".to_string(),
}],
unstaged: vec![],
untracked: vec!["new_file.txt".to_string()],
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"is_repo\":true"));
assert!(json.contains("\"branch\":\"main\""));
assert!(json.contains("\"ahead\":2"));
assert!(json.contains("\"behind\":1"));
}
#[test]
fn test_git_status_not_a_repo() {
let status = GitStatus {
is_repo: false,
branch: None,
upstream: None,
ahead: 0,
behind: 0,
staged: vec![],
unstaged: vec![],
untracked: vec![],
};
let json = serde_json::to_string(&status).unwrap();
let deserialized: GitStatus = serde_json::from_str(&json).unwrap();
assert!(!deserialized.is_repo);
assert!(deserialized.branch.is_none());
}
// ==================== GitFileChange struct tests ====================
#[test]
fn test_git_file_change_serialization() {
let change = GitFileChange {
path: "src/main.rs".to_string(),
status: "added".to_string(),
};
let json = serde_json::to_string(&change).unwrap();
assert!(json.contains("src/main.rs"));
assert!(json.contains("added"));
let deserialized: GitFileChange = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.path, "src/main.rs");
assert_eq!(deserialized.status, "added");
}
// ==================== GitBranch struct tests ====================
#[test]
fn test_git_branch_serialization() {
let branch = GitBranch {
name: "feature/new-feature".to_string(),
is_current: true,
is_remote: false,
};
let json = serde_json::to_string(&branch).unwrap();
assert!(json.contains("feature/new-feature"));
assert!(json.contains("\"is_current\":true"));
assert!(json.contains("\"is_remote\":false"));
}
#[test]
fn test_git_branch_remote() {
let branch = GitBranch {
name: "origin/main".to_string(),
is_current: false,
is_remote: true,
};
let json = serde_json::to_string(&branch).unwrap();
let deserialized: GitBranch = serde_json::from_str(&json).unwrap();
assert!(deserialized.is_remote);
assert!(!deserialized.is_current);
}
// ==================== GitLogEntry struct tests ====================
#[test]
fn test_git_log_entry_serialization() {
let entry = GitLogEntry {
hash: "abc123def456".to_string(),
short_hash: "abc123d".to_string(),
author: "Hikari".to_string(),
date: "2 hours ago".to_string(),
message: "feat: add new feature".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("abc123def456"));
assert!(json.contains("Hikari"));
assert!(json.contains("feat: add new feature"));
}
// ==================== git_status integration tests ====================
#[test]
fn test_git_status_not_a_git_repo() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path().to_string_lossy().to_string();
let result = git_status(working_dir);
assert!(result.is_ok());
let status = result.unwrap();
assert!(!status.is_repo);
assert!(status.branch.is_none());
assert!(status.staged.is_empty());
}
#[test]
fn test_git_status_empty_repo() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
let result = git_status(working_dir);
assert!(result.is_ok());
let status = result.unwrap();
assert!(status.is_repo);
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
assert!(status.untracked.is_empty());
}
#[test]
fn test_git_status_with_untracked_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create an untracked file
create_file(&temp_dir, "untracked.txt", "hello");
let result = git_status(working_dir);
assert!(result.is_ok());
let status = result.unwrap();
assert!(status.is_repo);
assert!(status.untracked.contains(&"untracked.txt".to_string()));
}
#[test]
fn test_git_status_with_staged_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create and stage a file
create_file(&temp_dir, "staged.txt", "hello");
run_git_command(&working_dir, &["add", "staged.txt"]).unwrap();
let result = git_status(working_dir);
assert!(result.is_ok());
let status = result.unwrap();
assert!(status.is_repo);
assert!(!status.staged.is_empty());
assert_eq!(status.staged[0].path, "staged.txt");
assert_eq!(status.staged[0].status, "added");
}
#[test]
fn test_git_status_with_modified_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create, stage, and commit a file
create_file(&temp_dir, "file.txt", "initial content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial commit"]).unwrap();
// Modify the file
create_file(&temp_dir, "file.txt", "modified content");
let result = git_status(working_dir);
assert!(result.is_ok());
let status = result.unwrap();
assert!(status.is_repo);
assert!(!status.unstaged.is_empty());
assert_eq!(status.unstaged[0].path, "file.txt");
assert_eq!(status.unstaged[0].status, "modified");
}
// ==================== git_diff integration tests ====================
#[test]
fn test_git_diff_no_changes() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
let result = git_diff(working_dir, None, false);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_git_diff_with_changes() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create and commit a file
create_file(&temp_dir, "file.txt", "initial content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Modify the file
create_file(&temp_dir, "file.txt", "modified content");
let result = git_diff(working_dir, None, false);
assert!(result.is_ok());
let diff = result.unwrap();
assert!(diff.contains("diff"));
assert!(diff.contains("file.txt"));
}
#[test]
fn test_git_diff_staged() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create and commit a file
create_file(&temp_dir, "file.txt", "initial content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Modify and stage the file
create_file(&temp_dir, "file.txt", "modified content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
let result = git_diff(working_dir, None, true);
assert!(result.is_ok());
let diff = result.unwrap();
assert!(diff.contains("diff"));
}
#[test]
fn test_git_diff_specific_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create and commit files
create_file(&temp_dir, "file1.txt", "content1");
create_file(&temp_dir, "file2.txt", "content2");
run_git_command(&working_dir, &["add", "-A"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Modify both files
create_file(&temp_dir, "file1.txt", "modified1");
create_file(&temp_dir, "file2.txt", "modified2");
// Get diff for only file1.txt
let result = git_diff(working_dir, Some("file1.txt".to_string()), false);
assert!(result.is_ok());
let diff = result.unwrap();
assert!(diff.contains("file1.txt"));
assert!(!diff.contains("file2.txt"));
}
// ==================== git_branches integration tests ====================
#[test]
fn test_git_branches_single_branch() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Need at least one commit for branches to show
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
let result = git_branches(working_dir);
assert!(result.is_ok());
let branches = result.unwrap();
assert!(!branches.is_empty());
// Should have at least one branch (main or master)
}
#[test]
fn test_git_branches_multiple_branches() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Initial commit
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Create additional branch
run_git_command(&working_dir, &["branch", "feature-branch"]).unwrap();
let result = git_branches(working_dir);
assert!(result.is_ok());
let branches = result.unwrap();
assert!(branches.len() >= 2);
assert!(branches.iter().any(|b| b.name == "feature-branch"));
}
// ==================== git_stage and git_unstage tests ====================
#[test]
fn test_git_stage_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
create_file(&temp_dir, "file.txt", "content");
let result = git_stage(working_dir.clone(), "file.txt".to_string());
assert!(result.is_ok());
// Verify file is staged
let status = git_status(working_dir).unwrap();
assert!(status.staged.iter().any(|f| f.path == "file.txt"));
}
#[test]
fn test_git_unstage_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// First, commit a file so we have a HEAD to restore from
create_file(&temp_dir, "file.txt", "initial content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Modify and stage the file
create_file(&temp_dir, "file.txt", "modified content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
let result = git_unstage(working_dir.clone(), "file.txt".to_string());
assert!(result.is_ok());
// Verify file is unstaged (should now be in unstaged/modified, not staged)
let status = git_status(working_dir).unwrap();
assert!(!status.staged.iter().any(|f| f.path == "file.txt"));
assert!(status.unstaged.iter().any(|f| f.path == "file.txt"));
}
#[test]
fn test_git_stage_all() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
create_file(&temp_dir, "file1.txt", "content1");
create_file(&temp_dir, "file2.txt", "content2");
let result = git_stage_all(working_dir.clone());
assert!(result.is_ok());
// Verify all files are staged
let status = git_status(working_dir).unwrap();
assert_eq!(status.staged.len(), 2);
}
// ==================== git_commit tests ====================
#[test]
fn test_git_commit() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
let result = git_commit(working_dir.clone(), "test commit message".to_string());
assert!(result.is_ok());
// Verify commit was made
let log = git_log(working_dir, Some(1)).unwrap();
assert!(!log.is_empty());
assert!(log[0].message.contains("test commit message"));
}
#[test]
fn test_git_commit_nothing_to_commit() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Need initial commit first
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Try to commit with nothing staged
let result = git_commit(working_dir, "empty commit".to_string());
assert!(result.is_err()); // Should fail because nothing to commit
}
// ==================== git_log tests ====================
#[test]
fn test_git_log_empty_repo() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
let result = git_log(working_dir, Some(10));
// May fail on empty repo or return empty
if let Ok(commits) = result {
assert!(commits.is_empty());
}
}
#[test]
fn test_git_log_with_commits() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Make multiple commits
for i in 1..=3 {
create_file(&temp_dir, &format!("file{}.txt", i), "content");
run_git_command(&working_dir, &["add", "-A"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
}
let result = git_log(working_dir, Some(10));
assert!(result.is_ok());
let log = result.unwrap();
assert_eq!(log.len(), 3);
assert!(log[0].message.contains("commit 3")); // Most recent first
assert!(log[2].message.contains("commit 1"));
}
#[test]
fn test_git_log_limit() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Make 5 commits
for i in 1..=5 {
create_file(&temp_dir, &format!("file{}.txt", i), "content");
run_git_command(&working_dir, &["add", "-A"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
}
// Only get last 2
let result = git_log(working_dir, Some(2));
assert!(result.is_ok());
let log = result.unwrap();
assert_eq!(log.len(), 2);
}
// ==================== git_discard tests ====================
#[test]
fn test_git_discard_changes() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create and commit a file
create_file(&temp_dir, "file.txt", "original content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Modify the file
create_file(&temp_dir, "file.txt", "modified content");
// Discard changes
let result = git_discard(working_dir.clone(), "file.txt".to_string());
assert!(result.is_ok());
// Verify file contents are restored
let content = fs::read_to_string(temp_dir.path().join("file.txt")).unwrap();
assert_eq!(content, "original content");
}
// ==================== git_create_branch tests ====================
#[test]
fn test_git_create_branch() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Initial commit required
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
let result = git_create_branch(working_dir.clone(), "new-branch".to_string());
assert!(result.is_ok());
// Verify branch exists and is current
let branches = git_branches(working_dir).unwrap();
assert!(branches.iter().any(|b| b.name == "new-branch" && b.is_current));
}
// ==================== git_checkout tests ====================
#[test]
fn test_git_checkout() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Initial commit required
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Create a branch
run_git_command(&working_dir, &["branch", "other-branch"]).unwrap();
// Checkout the branch
let result = git_checkout(working_dir.clone(), "other-branch".to_string());
assert!(result.is_ok());
// Verify current branch
let branches = git_branches(working_dir).unwrap();
let current = branches.iter().find(|b| b.is_current);
assert!(current.is_some());
assert_eq!(current.unwrap().name, "other-branch");
}
// ==================== run_git_command tests ====================
#[test]
fn test_run_git_command_success() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
let result = run_git_command(&working_dir, &["status"]);
assert!(result.is_ok());
}
#[test]
fn test_run_git_command_failure() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// This should fail because it's not a git repo
let result = run_git_command(&working_dir, &["log"]);
assert!(result.is_err());
}
#[test]
fn test_run_git_command_invalid_dir() {
let result = run_git_command("/nonexistent/path", &["status"]);
assert!(result.is_err());
}
}
-8
View File
@@ -151,14 +151,6 @@ pub fn run() {
search_clipboard_entries,
get_clipboard_languages,
update_clipboard_language,
list_directory,
read_file_content,
write_file_content,
create_file,
create_directory,
delete_file,
delete_directory,
rename_path,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
-182
View File
@@ -171,18 +171,6 @@ pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> {
mod tests {
use super::*;
fn create_test_action(id: &str, name: &str, is_default: bool) -> QuickAction {
QuickAction {
id: id.to_string(),
name: name.to_string(),
prompt: "Test prompt".to_string(),
icon: "star".to_string(),
is_default,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_default_quick_actions_exist() {
let defaults = get_default_quick_actions();
@@ -200,174 +188,4 @@ mod tests {
assert!(!action.icon.is_empty());
}
}
#[test]
fn test_default_quick_actions_count() {
let defaults = get_default_quick_actions();
// Should have 6 default actions
assert_eq!(defaults.len(), 6);
}
#[test]
fn test_default_quick_actions_have_unique_ids() {
let defaults = get_default_quick_actions();
let mut ids: Vec<&String> = defaults.iter().map(|a| &a.id).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), defaults.len());
}
#[test]
fn test_default_quick_actions_ids_start_with_default() {
let defaults = get_default_quick_actions();
assert!(defaults.iter().all(|a| a.id.starts_with("default-")));
}
#[test]
fn test_quick_action_serialization() {
let action = create_test_action("test-1", "Test Action", false);
let json = serde_json::to_string(&action).expect("Failed to serialize");
let parsed: QuickAction = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, action.id);
assert_eq!(parsed.name, action.name);
assert_eq!(parsed.prompt, action.prompt);
assert_eq!(parsed.icon, action.icon);
assert_eq!(parsed.is_default, action.is_default);
}
#[test]
fn test_quick_action_clone() {
let original = create_test_action("clone-test", "Clone Test", true);
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.name, cloned.name);
assert_eq!(original.is_default, cloned.is_default);
}
#[test]
#[allow(clippy::useless_vec)]
fn test_quick_action_sorting_defaults_first() {
let mut actions = vec![
create_test_action("custom-z", "Zebra", false),
create_test_action("default-a", "Apple", true),
create_test_action("custom-a", "Alpha", false),
create_test_action("default-z", "Zulu", true),
];
// Sort by: defaults first, then alphabetically by name
actions.sort_by(|a, b| {
let default_cmp = b.is_default.cmp(&a.is_default);
if default_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
default_cmp
}
});
// Defaults should come first
assert!(actions[0].is_default);
assert!(actions[1].is_default);
assert!(!actions[2].is_default);
assert!(!actions[3].is_default);
// Within defaults, alphabetically sorted
assert_eq!(actions[0].name, "Apple");
assert_eq!(actions[1].name, "Zulu");
// Within non-defaults, alphabetically sorted
assert_eq!(actions[2].name, "Alpha");
assert_eq!(actions[3].name, "Zebra");
}
#[test]
fn test_known_default_actions() {
let defaults = get_default_quick_actions();
let ids: Vec<&str> = defaults.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"default-review-pr"));
assert!(ids.contains(&"default-run-tests"));
assert!(ids.contains(&"default-explain-file"));
assert!(ids.contains(&"default-fix-error"));
assert!(ids.contains(&"default-write-tests"));
assert!(ids.contains(&"default-refactor"));
}
#[test]
fn test_default_action_icons() {
let defaults = get_default_quick_actions();
let icons: Vec<&str> = defaults.iter().map(|a| a.icon.as_str()).collect();
assert!(icons.contains(&"git-pull-request"));
assert!(icons.contains(&"play"));
assert!(icons.contains(&"file-text"));
assert!(icons.contains(&"alert-circle"));
assert!(icons.contains(&"check-square"));
assert!(icons.contains(&"refresh-cw"));
}
#[test]
fn test_quick_action_prompts_not_empty() {
let defaults = get_default_quick_actions();
for action in defaults {
assert!(
action.prompt.len() > 10,
"Prompt should be meaningful: {}",
action.name
);
}
}
#[test]
fn test_quick_action_timestamps() {
let action = create_test_action("time-test", "Time Test", false);
assert!(action.created_at <= action.updated_at);
}
#[test]
fn test_default_actions_have_same_timestamps() {
let defaults = get_default_quick_actions();
// All defaults are created at the same instant
let first_created = defaults[0].created_at;
let first_updated = defaults[0].updated_at;
for action in &defaults {
assert_eq!(action.created_at, first_created);
assert_eq!(action.updated_at, first_updated);
}
}
#[test]
fn test_action_retain_non_default() {
let mut actions = vec![
create_test_action("default-1", "Default 1", true),
create_test_action("custom-1", "Custom 1", false),
create_test_action("default-2", "Default 2", true),
create_test_action("custom-2", "Custom 2", false),
];
// Mimics reset_default_quick_actions behavior (retain non-defaults)
actions.retain(|a| !a.is_default);
assert_eq!(actions.len(), 2);
assert!(actions.iter().all(|a| !a.is_default));
}
#[test]
#[allow(clippy::useless_vec)]
fn test_action_find_by_id() {
let actions = vec![
create_test_action("action-1", "First", false),
create_test_action("action-2", "Second", false),
create_test_action("action-3", "Third", false),
];
let found = actions.iter().find(|a| a.id == "action-2");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "Second");
let not_found = actions.iter().find(|a| a.id == "action-999");
assert!(not_found.is_none());
}
}
-207
View File
@@ -145,30 +145,6 @@ pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn create_test_session(id: &str, name: &str) -> SavedSession {
SavedSession {
id: id.to_string(),
name: name.to_string(),
created_at: Utc::now(),
last_activity_at: Utc::now(),
working_directory: "/home/test".to_string(),
message_count: 5,
preview: "Hello world".to_string(),
messages: vec![],
}
}
fn create_test_message(id: &str, content: &str, msg_type: &str) -> SavedMessage {
SavedMessage {
id: id.to_string(),
message_type: msg_type.to_string(),
content: content.to_string(),
timestamp: Utc::now(),
tool_name: None,
}
}
#[test]
fn test_session_list_item_from_saved_session() {
@@ -188,187 +164,4 @@ mod tests {
assert_eq!(item.name, "Test Session");
assert_eq!(item.message_count, 5);
}
#[test]
fn test_session_list_item_preserves_all_fields() {
let created = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();
let last_activity = Utc.with_ymd_and_hms(2024, 1, 15, 14, 45, 0).unwrap();
let session = SavedSession {
id: "sess-123".to_string(),
name: "My Chat".to_string(),
created_at: created,
last_activity_at: last_activity,
working_directory: "/home/naomi/project".to_string(),
message_count: 42,
preview: "What is the meaning of life?".to_string(),
messages: vec![],
};
let item = SessionListItem::from(&session);
assert_eq!(item.id, "sess-123");
assert_eq!(item.name, "My Chat");
assert_eq!(item.created_at, created);
assert_eq!(item.last_activity_at, last_activity);
assert_eq!(item.working_directory, "/home/naomi/project");
assert_eq!(item.message_count, 42);
assert_eq!(item.preview, "What is the meaning of life?");
}
#[test]
fn test_saved_session_serialization() {
let session = create_test_session("test-1", "Test Session");
let json = serde_json::to_string(&session).expect("Failed to serialize");
let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, session.id);
assert_eq!(parsed.name, session.name);
assert_eq!(parsed.working_directory, session.working_directory);
}
#[test]
fn test_saved_message_serialization() {
let message = create_test_message("msg-1", "Hello!", "user");
let json = serde_json::to_string(&message).expect("Failed to serialize");
let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, message.id);
assert_eq!(parsed.content, message.content);
assert_eq!(parsed.message_type, "user");
}
#[test]
fn test_saved_message_with_tool_name() {
let message = SavedMessage {
id: "msg-tool-1".to_string(),
message_type: "tool".to_string(),
content: "File read successfully".to_string(),
timestamp: Utc::now(),
tool_name: Some("Read".to_string()),
};
let json = serde_json::to_string(&message).expect("Failed to serialize");
let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.tool_name, Some("Read".to_string()));
}
#[test]
fn test_session_with_messages_serialization() {
let mut session = create_test_session("sess-full", "Full Session");
session.messages = vec![
create_test_message("msg-1", "Hello!", "user"),
create_test_message("msg-2", "Hi there!", "assistant"),
create_test_message("msg-3", "Read file", "tool"),
];
session.message_count = 3;
let json = serde_json::to_string(&session).expect("Failed to serialize");
let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.messages.len(), 3);
assert_eq!(parsed.messages[0].content, "Hello!");
assert_eq!(parsed.messages[1].message_type, "assistant");
assert_eq!(parsed.messages[2].message_type, "tool");
}
#[test]
fn test_session_list_item_serialization() {
let item = SessionListItem {
id: "list-item-1".to_string(),
name: "Quick Chat".to_string(),
created_at: Utc::now(),
last_activity_at: Utc::now(),
working_directory: "/tmp".to_string(),
message_count: 10,
preview: "Short preview...".to_string(),
};
let json = serde_json::to_string(&item).expect("Failed to serialize");
let parsed: SessionListItem = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, item.id);
assert_eq!(parsed.name, item.name);
assert_eq!(parsed.preview, item.preview);
}
#[test]
fn test_message_type_field_rename() {
// The message_type field is renamed to "type" in JSON
let message = create_test_message("msg-1", "Test", "assistant");
let json = serde_json::to_string(&message).expect("Failed to serialize");
assert!(json.contains("\"type\":"));
assert!(!json.contains("\"message_type\":"));
}
#[test]
fn test_session_default_empty_messages() {
let session = SavedSession {
id: "empty".to_string(),
name: "Empty".to_string(),
created_at: Utc::now(),
last_activity_at: Utc::now(),
working_directory: "/".to_string(),
message_count: 0,
preview: "".to_string(),
messages: vec![],
};
assert!(session.messages.is_empty());
assert_eq!(session.message_count, 0);
}
#[test]
#[allow(clippy::useless_vec)]
fn test_session_sorting_by_activity() {
let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let new_time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
let mut sessions = vec![
SessionListItem {
id: "old".to_string(),
name: "Old Session".to_string(),
created_at: old_time,
last_activity_at: old_time,
working_directory: "/old".to_string(),
message_count: 1,
preview: "Old".to_string(),
},
SessionListItem {
id: "new".to_string(),
name: "New Session".to_string(),
created_at: new_time,
last_activity_at: new_time,
working_directory: "/new".to_string(),
message_count: 1,
preview: "New".to_string(),
},
];
// Sort by last activity, most recent first (mimics list_sessions behavior)
sessions.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
assert_eq!(sessions[0].id, "new");
assert_eq!(sessions[1].id, "old");
}
#[test]
fn test_session_clone() {
let original = create_test_session("clone-test", "Clone Test");
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.name, cloned.name);
}
#[test]
fn test_message_clone() {
let original = create_test_message("msg-clone", "Content", "user");
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.content, cloned.content);
}
}
-213
View File
@@ -205,19 +205,6 @@ pub async fn reset_default_snippets(app: AppHandle) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
fn create_test_snippet(id: &str, name: &str, category: &str, is_default: bool) -> Snippet {
Snippet {
id: id.to_string(),
name: name.to_string(),
content: "Test content".to_string(),
category: category.to_string(),
is_default,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_default_snippets_exist() {
@@ -236,204 +223,4 @@ mod tests {
assert!(!snippet.category.is_empty());
}
}
#[test]
fn test_default_snippets_count() {
let defaults = get_default_snippets();
// Should have 8 default snippets
assert_eq!(defaults.len(), 8);
}
#[test]
fn test_default_snippets_have_unique_ids() {
let defaults = get_default_snippets();
let ids: HashSet<&String> = defaults.iter().map(|s| &s.id).collect();
assert_eq!(ids.len(), defaults.len());
}
#[test]
fn test_default_snippets_ids_start_with_default() {
let defaults = get_default_snippets();
assert!(defaults.iter().all(|s| s.id.starts_with("default-")));
}
#[test]
fn test_snippet_serialization() {
let snippet = create_test_snippet("test-1", "Test Snippet", "Testing", false);
let json = serde_json::to_string(&snippet).expect("Failed to serialize");
let parsed: Snippet = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, snippet.id);
assert_eq!(parsed.name, snippet.name);
assert_eq!(parsed.content, snippet.content);
assert_eq!(parsed.category, snippet.category);
assert_eq!(parsed.is_default, snippet.is_default);
}
#[test]
fn test_snippet_clone() {
let original = create_test_snippet("clone-test", "Clone Test", "Category", true);
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.name, cloned.name);
assert_eq!(original.is_default, cloned.is_default);
}
#[test]
#[allow(clippy::useless_vec)]
fn test_snippet_sorting_by_category_then_name() {
let mut snippets = vec![
create_test_snippet("s1", "Zebra", "B-Category", false),
create_test_snippet("s2", "Apple", "A-Category", false),
create_test_snippet("s3", "Banana", "B-Category", false),
create_test_snippet("s4", "Alpha", "A-Category", false),
];
// Sort by category, then by name (mimics list_snippets behavior)
snippets.sort_by(|a, b| {
let cat_cmp = a.category.cmp(&b.category);
if cat_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
cat_cmp
}
});
// A-Category should come first
assert_eq!(snippets[0].category, "A-Category");
assert_eq!(snippets[1].category, "A-Category");
assert_eq!(snippets[2].category, "B-Category");
assert_eq!(snippets[3].category, "B-Category");
// Within categories, alphabetically by name
assert_eq!(snippets[0].name, "Alpha");
assert_eq!(snippets[1].name, "Apple");
assert_eq!(snippets[2].name, "Banana");
assert_eq!(snippets[3].name, "Zebra");
}
#[test]
fn test_known_default_snippets() {
let defaults = get_default_snippets();
let ids: Vec<&str> = defaults.iter().map(|s| s.id.as_str()).collect();
assert!(ids.contains(&"default-explain-code"));
assert!(ids.contains(&"default-fix-error"));
assert!(ids.contains(&"default-write-tests"));
assert!(ids.contains(&"default-refactor"));
assert!(ids.contains(&"default-optimize"));
assert!(ids.contains(&"default-review-pr"));
assert!(ids.contains(&"default-add-comments"));
assert!(ids.contains(&"default-security-review"));
}
#[test]
fn test_default_snippet_categories() {
let defaults = get_default_snippets();
let categories: HashSet<&String> = defaults.iter().map(|s| &s.category).collect();
assert!(categories.contains(&"Code Review".to_string()));
assert!(categories.contains(&"Debugging".to_string()));
assert!(categories.contains(&"Testing".to_string()));
assert!(categories.contains(&"Performance".to_string()));
assert!(categories.contains(&"Documentation".to_string()));
assert!(categories.contains(&"Security".to_string()));
}
#[test]
fn test_snippet_content_not_empty() {
let defaults = get_default_snippets();
for snippet in defaults {
assert!(
snippet.content.len() > 10,
"Content should be meaningful: {}",
snippet.name
);
}
}
#[test]
fn test_snippet_timestamps() {
let snippet = create_test_snippet("time-test", "Time Test", "Cat", false);
assert!(snippet.created_at <= snippet.updated_at);
}
#[test]
fn test_default_snippets_have_same_timestamps() {
let defaults = get_default_snippets();
// All defaults are created at the same instant
let first_created = defaults[0].created_at;
let first_updated = defaults[0].updated_at;
for snippet in &defaults {
assert_eq!(snippet.created_at, first_created);
assert_eq!(snippet.updated_at, first_updated);
}
}
#[test]
fn test_snippet_retain_non_default() {
let mut snippets = vec![
create_test_snippet("default-1", "Default 1", "Cat", true),
create_test_snippet("custom-1", "Custom 1", "Cat", false),
create_test_snippet("default-2", "Default 2", "Cat", true),
create_test_snippet("custom-2", "Custom 2", "Cat", false),
];
// Mimics reset_default_snippets behavior (retain non-defaults)
snippets.retain(|s| !s.is_default);
assert_eq!(snippets.len(), 2);
assert!(snippets.iter().all(|s| !s.is_default));
}
#[test]
#[allow(clippy::useless_vec)]
fn test_snippet_find_by_id() {
let snippets = vec![
create_test_snippet("snippet-1", "First", "Cat", false),
create_test_snippet("snippet-2", "Second", "Cat", false),
create_test_snippet("snippet-3", "Third", "Cat", false),
];
let found = snippets.iter().find(|s| s.id == "snippet-2");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "Second");
let not_found = snippets.iter().find(|s| s.id == "snippet-999");
assert!(not_found.is_none());
}
#[test]
#[allow(clippy::useless_vec)]
fn test_extract_categories_sorted_and_deduped() {
let snippets = vec![
create_test_snippet("s1", "S1", "Zebra", false),
create_test_snippet("s2", "S2", "Alpha", false),
create_test_snippet("s3", "S3", "Beta", false),
create_test_snippet("s4", "S4", "Alpha", false), // Duplicate
];
let mut categories: Vec<String> = snippets.iter().map(|s| s.category.clone()).collect();
categories.sort();
categories.dedup();
assert_eq!(categories.len(), 3);
assert_eq!(categories[0], "Alpha");
assert_eq!(categories[1], "Beta");
assert_eq!(categories[2], "Zebra");
}
#[test]
fn test_snippet_category_code_review_count() {
let defaults = get_default_snippets();
let code_review_count = defaults
.iter()
.filter(|s| s.category == "Code Review")
.count();
// There should be multiple code review snippets
assert!(code_review_count >= 2);
}
}
-278
View File
@@ -369,36 +369,6 @@ mod tests {
assert!((cost - 0.165).abs() < 0.0001);
}
#[test]
fn test_cost_calculation_opus_45() {
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
// Same pricing as Opus 4
assert!((cost - 0.165).abs() < 0.0001);
}
#[test]
fn test_cost_calculation_haiku() {
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022");
// 1000 input * $1/M = $0.001
// 2000 output * $5/M = $0.010
// Total = $0.011
assert!((cost - 0.011).abs() < 0.0001);
}
#[test]
fn test_cost_calculation_unknown_defaults_to_sonnet() {
let cost = calculate_cost(1000, 2000, "some-unknown-model");
// Should default to Sonnet pricing
assert!((cost - 0.033).abs() < 0.0001);
}
#[test]
fn test_cost_calculation_legacy_sonnet() {
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022");
// Same as Sonnet 4 pricing
assert!((cost - 0.033).abs() < 0.0001);
}
#[test]
fn test_usage_stats_accumulation() {
let mut stats = UsageStats::new();
@@ -411,28 +381,6 @@ mod tests {
assert!((stats.total_cost_usd - 0.033).abs() < 0.0001);
}
#[test]
fn test_usage_stats_multiple_accumulations() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
assert_eq!(stats.total_input_tokens, 1500);
assert_eq!(stats.total_output_tokens, 1500);
assert_eq!(stats.session_input_tokens, 1500);
assert_eq!(stats.session_output_tokens, 1500);
}
#[test]
fn test_usage_stats_model_updated() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
stats.add_usage(500, 500, "claude-opus-4-20250514");
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
}
#[test]
fn test_session_reset() {
let mut stats = UsageStats::new();
@@ -446,230 +394,4 @@ mod tests {
assert_eq!(stats.session_cost_usd, 0.0);
assert!(stats.total_cost_usd > 0.0);
}
#[test]
fn test_session_reset_clears_session_stats() {
let mut stats = UsageStats::new();
stats.increment_messages();
stats.increment_messages();
stats.increment_code_blocks();
stats.increment_files_edited();
stats.increment_files_created();
stats.increment_tool_usage("Read");
stats.reset_session();
assert_eq!(stats.session_messages_exchanged, 0);
assert_eq!(stats.session_code_blocks_generated, 0);
assert_eq!(stats.session_files_edited, 0);
assert_eq!(stats.session_files_created, 0);
assert!(stats.session_tools_usage.is_empty());
}
#[test]
fn test_increment_messages() {
let mut stats = UsageStats::new();
stats.increment_messages();
stats.increment_messages();
stats.increment_messages();
assert_eq!(stats.messages_exchanged, 3);
assert_eq!(stats.session_messages_exchanged, 3);
}
#[test]
fn test_increment_code_blocks() {
let mut stats = UsageStats::new();
stats.increment_code_blocks();
stats.increment_code_blocks();
assert_eq!(stats.code_blocks_generated, 2);
assert_eq!(stats.session_code_blocks_generated, 2);
}
#[test]
fn test_increment_files_edited() {
let mut stats = UsageStats::new();
stats.increment_files_edited();
assert_eq!(stats.files_edited, 1);
assert_eq!(stats.session_files_edited, 1);
}
#[test]
fn test_increment_files_created() {
let mut stats = UsageStats::new();
stats.increment_files_created();
assert_eq!(stats.files_created, 1);
assert_eq!(stats.session_files_created, 1);
}
#[test]
fn test_increment_tool_usage() {
let mut stats = UsageStats::new();
stats.increment_tool_usage("Read");
stats.increment_tool_usage("Read");
stats.increment_tool_usage("Write");
assert_eq!(stats.tools_usage.get("Read"), Some(&2));
assert_eq!(stats.tools_usage.get("Write"), Some(&1));
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2));
assert_eq!(stats.session_tools_usage.get("Write"), Some(&1));
}
#[test]
fn test_session_duration_tracking() {
let mut stats = UsageStats::new();
stats.session_start = Some(Instant::now());
// Verify duration is returned (u64 is always non-negative)
let _duration = stats.get_session_duration();
}
#[test]
fn test_session_duration_without_start() {
let mut stats = UsageStats::new();
stats.session_start = None;
stats.session_duration_seconds = 100;
// Should return the stored value when no start time
let duration = stats.get_session_duration();
assert_eq!(duration, 100);
}
#[test]
fn test_is_consecutive_day_true() {
assert!(is_consecutive_day("2024-01-15", "2024-01-16"));
assert!(is_consecutive_day("2024-12-31", "2025-01-01"));
}
#[test]
fn test_is_consecutive_day_false() {
assert!(!is_consecutive_day("2024-01-15", "2024-01-15")); // Same day
assert!(!is_consecutive_day("2024-01-15", "2024-01-17")); // Gap
assert!(!is_consecutive_day("2024-01-15", "2024-01-14")); // Backwards
}
#[test]
fn test_is_consecutive_day_invalid_dates() {
assert!(!is_consecutive_day("invalid", "2024-01-01"));
assert!(!is_consecutive_day("2024-01-01", "invalid"));
assert!(!is_consecutive_day("invalid", "also-invalid"));
}
#[test]
fn test_persisted_stats_from_usage_stats() {
let mut stats = UsageStats::new();
stats.total_input_tokens = 5000;
stats.total_output_tokens = 10000;
stats.total_cost_usd = 1.23;
stats.messages_exchanged = 50;
stats.sessions_started = 5;
stats.consecutive_days = 3;
let persisted = PersistedStats::from(&stats);
assert_eq!(persisted.total_input_tokens, 5000);
assert_eq!(persisted.total_output_tokens, 10000);
assert_eq!(persisted.total_cost_usd, 1.23);
assert_eq!(persisted.messages_exchanged, 50);
assert_eq!(persisted.sessions_started, 5);
assert_eq!(persisted.consecutive_days, 3);
}
#[test]
fn test_apply_persisted_stats() {
let persisted = PersistedStats {
total_input_tokens: 10000,
total_output_tokens: 20000,
total_cost_usd: 5.50,
messages_exchanged: 100,
code_blocks_generated: 25,
files_edited: 10,
files_created: 5,
tools_usage: {
let mut map = HashMap::new();
map.insert("Read".to_string(), 50);
map
},
sessions_started: 10,
consecutive_days: 7,
total_days_used: 14,
morning_sessions: 3,
night_sessions: 2,
last_session_date: Some("2024-06-15".to_string()),
};
let mut stats = UsageStats::new();
stats.apply_persisted(persisted);
assert_eq!(stats.total_input_tokens, 10000);
assert_eq!(stats.total_output_tokens, 20000);
assert_eq!(stats.total_cost_usd, 5.50);
assert_eq!(stats.messages_exchanged, 100);
assert_eq!(stats.tools_usage.get("Read"), Some(&50));
assert_eq!(stats.consecutive_days, 7);
assert_eq!(stats.morning_sessions, 3);
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
}
#[test]
fn test_usage_stats_default() {
let stats = UsageStats::default();
assert_eq!(stats.total_input_tokens, 0);
assert_eq!(stats.total_output_tokens, 0);
assert_eq!(stats.total_cost_usd, 0.0);
assert!(stats.model.is_none());
}
#[test]
fn test_persisted_stats_default() {
let persisted = PersistedStats::default();
assert_eq!(persisted.total_input_tokens, 0);
assert!(persisted.last_session_date.is_none());
}
#[test]
fn test_usage_stats_serialization() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
stats.increment_messages();
// UsageStats should be serializable (for events)
let json = serde_json::to_string(&stats).expect("Failed to serialize");
assert!(json.contains("total_input_tokens"));
assert!(json.contains("1000"));
}
#[test]
fn test_persisted_stats_serialization() {
let persisted = PersistedStats {
total_input_tokens: 1234,
total_output_tokens: 5678,
total_cost_usd: 0.99,
..Default::default()
};
let json = serde_json::to_string(&persisted).expect("Failed to serialize");
let parsed: PersistedStats = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.total_input_tokens, 1234);
assert_eq!(parsed.total_output_tokens, 5678);
assert!((parsed.total_cost_usd - 0.99).abs() < 0.0001);
}
#[test]
fn test_stats_update_event_serialization() {
let mut stats = UsageStats::new();
stats.add_usage(100, 200, "claude-sonnet-4-20250514");
let event = StatsUpdateEvent { stats };
let json = serde_json::to_string(&event).expect("Failed to serialize");
assert!(json.contains("stats"));
assert!(json.contains("total_input_tokens"));
}
}
-287
View File
@@ -137,290 +137,3 @@ pub type SharedTempFileManager = Arc<Mutex<TempFileManager>>;
pub fn create_shared_temp_manager() -> Result<SharedTempFileManager, String> {
Ok(Arc::new(Mutex::new(TempFileManager::new()?)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
// Helper to create a TempFileManager with a custom base directory for testing
fn create_test_manager(base_dir: PathBuf) -> TempFileManager {
if !base_dir.exists() {
fs::create_dir_all(&base_dir).expect("Failed to create test temp dir");
}
TempFileManager {
base_dir,
files: HashMap::new(),
}
}
#[test]
fn test_new_creates_base_directory() {
let manager = TempFileManager::new().expect("Failed to create TempFileManager");
assert!(manager.base_dir.exists());
}
#[test]
fn test_get_base_dir_returns_correct_path() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let manager = create_test_manager(base_path.clone());
assert_eq!(manager.get_base_dir(), base_path.as_path());
}
#[test]
fn test_save_file_creates_file_with_content() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"Hello, world!";
let result = manager.save_file("conv-1", data, Some("test.txt"));
assert!(result.is_ok());
let file_path = result.unwrap();
assert!(file_path.exists());
let content = fs::read(&file_path).expect("Failed to read file");
assert_eq!(content, data);
}
#[test]
fn test_save_file_uses_correct_extension() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"test data";
let result = manager.save_file("conv-1", data, Some("document.pdf"));
assert!(result.is_ok());
let file_path = result.unwrap();
assert_eq!(file_path.extension().unwrap(), "pdf");
}
#[test]
fn test_save_file_uses_bin_extension_when_no_filename() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"binary data";
let result = manager.save_file("conv-1", data, None);
assert!(result.is_ok());
let file_path = result.unwrap();
assert_eq!(file_path.extension().unwrap(), "bin");
}
#[test]
fn test_register_file_tracks_file_path() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let file_path = PathBuf::from("/some/path/file.txt");
manager.register_file("conv-1", file_path.clone());
let files = manager.get_files_for_conversation("conv-1");
assert_eq!(files.len(), 1);
assert_eq!(files[0], file_path);
}
#[test]
fn test_get_files_for_conversation_returns_empty_for_unknown() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let manager = create_test_manager(base_path);
let files = manager.get_files_for_conversation("unknown-conv");
assert!(files.is_empty());
}
#[test]
fn test_get_files_for_conversation_returns_all_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"test";
manager.save_file("conv-1", data, Some("file1.txt")).unwrap();
manager.save_file("conv-1", data, Some("file2.txt")).unwrap();
manager.save_file("conv-2", data, Some("file3.txt")).unwrap();
let files_conv1 = manager.get_files_for_conversation("conv-1");
let files_conv2 = manager.get_files_for_conversation("conv-2");
assert_eq!(files_conv1.len(), 2);
assert_eq!(files_conv2.len(), 1);
}
#[test]
fn test_cleanup_conversation_removes_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"test";
let file_path = manager.save_file("conv-1", data, Some("test.txt")).unwrap();
assert!(file_path.exists());
let result = manager.cleanup_conversation("conv-1");
assert!(result.is_ok());
assert!(!file_path.exists());
assert!(manager.get_files_for_conversation("conv-1").is_empty());
}
#[test]
fn test_cleanup_conversation_handles_missing_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
// Register a file that doesn't exist
manager.register_file("conv-1", PathBuf::from("/nonexistent/file.txt"));
// Should not error, just skip missing files
let result = manager.cleanup_conversation("conv-1");
assert!(result.is_ok());
}
#[test]
fn test_cleanup_conversation_for_unknown_returns_ok() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let result = manager.cleanup_conversation("unknown-conv");
assert!(result.is_ok());
}
#[test]
fn test_cleanup_all_removes_all_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"test";
let file1 = manager.save_file("conv-1", data, Some("f1.txt")).unwrap();
let file2 = manager.save_file("conv-2", data, Some("f2.txt")).unwrap();
assert!(file1.exists());
assert!(file2.exists());
let result = manager.cleanup_all();
assert!(result.is_ok());
assert!(!file1.exists());
assert!(!file2.exists());
assert!(manager.files.is_empty());
}
#[test]
fn test_cleanup_orphaned_files_removes_untracked() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path.clone());
// Create a tracked file
let data = b"tracked";
let tracked_path = manager.save_file("conv-1", data, Some("tracked.txt")).unwrap();
// Create an untracked (orphaned) file directly in the temp directory
let orphan_path = base_path.join("orphan.txt");
fs::write(&orphan_path, b"orphan").expect("Failed to create orphan file");
assert!(tracked_path.exists());
assert!(orphan_path.exists());
let result = manager.cleanup_orphaned_files();
assert!(result.is_ok());
assert_eq!(result.unwrap(), 1); // One orphan removed
assert!(tracked_path.exists()); // Tracked file still exists
assert!(!orphan_path.exists()); // Orphan removed
}
#[test]
fn test_cleanup_orphaned_returns_zero_when_none() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"test";
manager.save_file("conv-1", data, Some("test.txt")).unwrap();
let result = manager.cleanup_orphaned_files();
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[test]
fn test_cleanup_orphaned_returns_zero_when_dir_missing() {
let mut manager = TempFileManager {
base_dir: PathBuf::from("/nonexistent/dir"),
files: HashMap::new(),
};
let result = manager.cleanup_orphaned_files();
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[test]
fn test_default_creates_manager() {
// Default should work as long as we can create temp directories
let manager = TempFileManager::default();
assert!(manager.base_dir.exists());
}
#[test]
fn test_create_shared_temp_manager() {
let result = create_shared_temp_manager();
assert!(result.is_ok());
let shared = result.unwrap();
let manager = shared.lock();
assert!(manager.base_dir.exists());
}
#[test]
fn test_multiple_files_same_conversation() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
// Save multiple files to same conversation
for i in 0..5 {
let data = format!("content {}", i);
manager
.save_file("conv-1", data.as_bytes(), Some(&format!("file{}.txt", i)))
.unwrap();
}
let files = manager.get_files_for_conversation("conv-1");
assert_eq!(files.len(), 5);
// Each file should have unique content
for (i, file_path) in files.iter().enumerate() {
let content = fs::read_to_string(file_path).expect("Failed to read");
assert_eq!(content, format!("content {}", i));
}
}
#[test]
fn test_file_paths_contain_conversation_id() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let file_path = manager
.save_file("my-conversation-id", b"test", Some("test.txt"))
.unwrap();
let filename = file_path.file_name().unwrap().to_str().unwrap();
assert!(filename.starts_with("my-conversation-id_"));
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.1.0",
"version": "0.3.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
-414
View File
@@ -1,414 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import {
slashCommands,
parseSlashCommand,
getMatchingCommands,
isSlashCommand,
type SlashCommand,
} from "./slashCommands";
// Mock all external dependencies
vi.mock("svelte/store", () => ({
get: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("$lib/stores/claude", () => ({
claudeStore: {
addLine: vi.fn(),
clearTerminal: vi.fn(),
activeConversationId: { subscribe: vi.fn() },
currentWorkingDirectory: { subscribe: vi.fn() },
setWorkingDirectory: vi.fn(),
getConversationHistory: vi.fn(),
},
}));
vi.mock("$lib/stores/character", () => ({
characterState: {
setState: vi.fn(),
setTemporaryState: vi.fn(),
},
}));
vi.mock("$lib/tauri", () => ({
setSkipNextGreeting: vi.fn(),
}));
vi.mock("$lib/stores/search", () => ({
searchState: {
setQuery: vi.fn(),
clear: vi.fn(),
},
}));
describe("slashCommands", () => {
describe("slashCommands array", () => {
it("contains expected commands", () => {
const commandNames = slashCommands.map((cmd) => cmd.name);
expect(commandNames).toContain("cd");
expect(commandNames).toContain("clear");
expect(commandNames).toContain("new");
expect(commandNames).toContain("help");
expect(commandNames).toContain("search");
expect(commandNames).toContain("summarise");
expect(commandNames).toContain("skill");
});
it("has 7 commands total", () => {
expect(slashCommands.length).toBe(7);
});
it("each command has required properties", () => {
slashCommands.forEach((cmd) => {
expect(cmd.name).toBeDefined();
expect(typeof cmd.name).toBe("string");
expect(cmd.name.length).toBeGreaterThan(0);
expect(cmd.description).toBeDefined();
expect(typeof cmd.description).toBe("string");
expect(cmd.description.length).toBeGreaterThan(0);
expect(cmd.usage).toBeDefined();
expect(typeof cmd.usage).toBe("string");
expect(cmd.usage.startsWith("/")).toBe(true);
expect(cmd.execute).toBeDefined();
expect(typeof cmd.execute).toBe("function");
});
});
it("cd command has correct metadata", () => {
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd");
expect(cdCmd).toBeDefined();
expect(cdCmd!.description).toBe("Change the working directory");
expect(cdCmd!.usage).toBe("/cd <path>");
});
it("clear command has correct metadata", () => {
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear");
expect(clearCmd).toBeDefined();
expect(clearCmd!.description).toBe("Clear the terminal display (keeps conversation context)");
expect(clearCmd!.usage).toBe("/clear");
});
it("new command has correct metadata", () => {
const newCmd = slashCommands.find((cmd) => cmd.name === "new");
expect(newCmd).toBeDefined();
expect(newCmd!.description).toBe("Start a fresh conversation (resets context)");
expect(newCmd!.usage).toBe("/new");
});
it("help command has correct metadata", () => {
const helpCmd = slashCommands.find((cmd) => cmd.name === "help");
expect(helpCmd).toBeDefined();
expect(helpCmd!.description).toBe("Show available slash commands");
expect(helpCmd!.usage).toBe("/help");
});
it("search command has correct metadata", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search");
expect(searchCmd).toBeDefined();
expect(searchCmd!.description).toBe("Search within the conversation (use /search to clear)");
expect(searchCmd!.usage).toBe("/search [query]");
});
it("summarise command has correct metadata", () => {
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise");
expect(summariseCmd).toBeDefined();
expect(summariseCmd!.description).toBe("Get a summary of the entire conversation");
expect(summariseCmd!.usage).toBe("/summarise");
});
it("skill command has correct metadata", () => {
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill");
expect(skillCmd).toBeDefined();
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
expect(skillCmd!.usage).toBe("/skill [name] [data]");
});
});
describe("parseSlashCommand", () => {
it("returns null for non-slash input", () => {
const result = parseSlashCommand("hello world");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("returns null for empty string", () => {
const result = parseSlashCommand("");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("returns null for whitespace only", () => {
const result = parseSlashCommand(" ");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("parses /cd command without args", () => {
const result = parseSlashCommand("/cd");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("");
});
it("parses /cd command with path argument", () => {
const result = parseSlashCommand("/cd /home/naomi/code");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("/home/naomi/code");
});
it("parses /clear command", () => {
const result = parseSlashCommand("/clear");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("clear");
expect(result.args).toBe("");
});
it("parses /new command", () => {
const result = parseSlashCommand("/new");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("new");
expect(result.args).toBe("");
});
it("parses /help command", () => {
const result = parseSlashCommand("/help");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("help");
expect(result.args).toBe("");
});
it("parses /search command with query", () => {
const result = parseSlashCommand("/search hello world");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("search");
expect(result.args).toBe("hello world");
});
it("parses /search command without query", () => {
const result = parseSlashCommand("/search");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("search");
expect(result.args).toBe("");
});
it("parses /summarise command", () => {
const result = parseSlashCommand("/summarise");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("summarise");
expect(result.args).toBe("");
});
it("parses /skill command with name and data", () => {
const result = parseSlashCommand("/skill onboard-mentee john@example.com");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("skill");
expect(result.args).toBe("onboard-mentee john@example.com");
});
it("parses /skill command with name only", () => {
const result = parseSlashCommand("/skill onboard-mentee");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("skill");
expect(result.args).toBe("onboard-mentee");
});
it("parses /skill command without arguments", () => {
const result = parseSlashCommand("/skill");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("skill");
expect(result.args).toBe("");
});
it("returns null for unknown command", () => {
const result = parseSlashCommand("/unknown");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("is case insensitive for command names", () => {
const result1 = parseSlashCommand("/CD /path");
expect(result1.command).not.toBeNull();
expect(result1.command!.name).toBe("cd");
const result2 = parseSlashCommand("/CLEAR");
expect(result2.command).not.toBeNull();
expect(result2.command!.name).toBe("clear");
const result3 = parseSlashCommand("/Help");
expect(result3.command).not.toBeNull();
expect(result3.command!.name).toBe("help");
});
it("handles leading whitespace", () => {
const result = parseSlashCommand(" /cd /path");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("/path");
});
it("handles trailing whitespace", () => {
const result = parseSlashCommand("/cd /path ");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("/path");
});
it("handles multiple spaces between args", () => {
const result = parseSlashCommand("/search hello world");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("search");
expect(result.args).toBe("hello world");
});
});
describe("getMatchingCommands", () => {
it("returns empty array for non-slash input", () => {
const result = getMatchingCommands("hello");
expect(result).toEqual([]);
});
it("returns empty array for empty string", () => {
const result = getMatchingCommands("");
expect(result).toEqual([]);
});
it("returns all commands for just slash", () => {
const result = getMatchingCommands("/");
expect(result.length).toBe(slashCommands.length);
});
it("returns matching commands for partial input", () => {
const result = getMatchingCommands("/c");
const names = result.map((cmd) => cmd.name);
expect(names).toContain("cd");
expect(names).toContain("clear");
expect(names).not.toContain("help");
});
it("returns single command for exact match", () => {
const result = getMatchingCommands("/cd");
expect(result.length).toBe(1);
expect(result[0].name).toBe("cd");
});
it("returns single command for partial unique match", () => {
const result = getMatchingCommands("/cl");
expect(result.length).toBe(1);
expect(result[0].name).toBe("clear");
});
it("returns matching commands for /s prefix", () => {
const result = getMatchingCommands("/s");
const names = result.map((cmd) => cmd.name);
expect(names).toContain("search");
expect(names).toContain("summarise");
expect(names).toContain("skill");
});
it("is case insensitive", () => {
const result1 = getMatchingCommands("/C");
const result2 = getMatchingCommands("/c");
expect(result1.length).toBe(result2.length);
});
it("returns empty array for no matches", () => {
const result = getMatchingCommands("/xyz");
expect(result).toEqual([]);
});
it("handles whitespace correctly", () => {
const result = getMatchingCommands(" /c");
const names = result.map((cmd) => cmd.name);
expect(names).toContain("cd");
expect(names).toContain("clear");
});
it("returns command for full command name", () => {
const result = getMatchingCommands("/help");
expect(result.length).toBe(1);
expect(result[0].name).toBe("help");
});
it("returns command for /new", () => {
const result = getMatchingCommands("/n");
expect(result.length).toBe(1);
expect(result[0].name).toBe("new");
});
});
describe("isSlashCommand", () => {
it("returns true for input starting with slash", () => {
expect(isSlashCommand("/cd")).toBe(true);
expect(isSlashCommand("/")).toBe(true);
expect(isSlashCommand("/help")).toBe(true);
expect(isSlashCommand("/unknown")).toBe(true);
});
it("returns false for non-slash input", () => {
expect(isSlashCommand("hello")).toBe(false);
expect(isSlashCommand("")).toBe(false);
expect(isSlashCommand("cd")).toBe(false);
});
it("handles whitespace correctly", () => {
expect(isSlashCommand(" /cd")).toBe(true);
expect(isSlashCommand(" hello")).toBe(false);
});
it("returns false for slash in middle of string", () => {
expect(isSlashCommand("hello/world")).toBe(false);
});
});
describe("SlashCommand interface", () => {
it("can create a valid slash command object", () => {
const testCommand: SlashCommand = {
name: "test",
description: "A test command",
usage: "/test [arg]",
execute: vi.fn(),
};
expect(testCommand.name).toBe("test");
expect(testCommand.description).toBe("A test command");
expect(testCommand.usage).toBe("/test [arg]");
expect(typeof testCommand.execute).toBe("function");
});
it("execute can be async function", () => {
const asyncCommand: SlashCommand = {
name: "async",
description: "An async command",
usage: "/async",
execute: async () => {
await Promise.resolve();
},
};
expect(asyncCommand.execute("")).toBeInstanceOf(Promise);
});
it("execute can be sync function", () => {
const syncCommand: SlashCommand = {
name: "sync",
description: "A sync command",
usage: "/sync",
execute: () => {
// Synchronous execution
},
};
const result = syncCommand.execute("");
// Sync function returns undefined, not a Promise
expect(result).toBeUndefined();
});
});
});
-3
View File
@@ -249,9 +249,6 @@
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
+7 -243
View File
@@ -3,9 +3,7 @@
configStore,
type HikariConfig,
type Theme,
type CustomThemeColors,
applyFontSize,
applyCustomThemeColors,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
@@ -35,20 +33,8 @@
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
});
let showCustomThemeEditor = $state(false);
let isOpen = $state(false);
let isSaving = $state(false);
let saveError: string | null = $state(null);
@@ -105,33 +91,9 @@
async function handleThemeChange(theme: Theme) {
config.theme = theme;
showCustomThemeEditor = theme === "custom";
await configStore.setTheme(theme, config.custom_theme_colors);
await configStore.setTheme(theme);
}
function handleCustomColorChange(key: keyof CustomThemeColors, value: string) {
config.custom_theme_colors = {
...config.custom_theme_colors,
[key]: value || null,
};
// Live preview
if (config.theme === "custom") {
applyCustomThemeColors(config.custom_theme_colors);
}
}
// Default dark theme colors for color picker defaults
const defaultDarkColors: Required<Record<keyof CustomThemeColors, string>> = {
bg_primary: "#1a1a2e",
bg_secondary: "#16213e",
bg_terminal: "#0f0f1a",
accent_primary: "#e94560",
accent_secondary: "#ff6b9d",
text_primary: "#ffffff",
text_secondary: "#a0a0a0",
border_color: "#2a2a4a",
};
function toggleTool(tool: string) {
if (config.auto_granted_tools.includes(tool)) {
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
@@ -458,12 +420,11 @@
<!-- Theme Selection -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Theme</span>
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
<label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label>
<div class="flex gap-2">
<button
onclick={() => handleThemeChange("dark")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
'dark'
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme === 'dark'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
@@ -471,8 +432,7 @@
</button>
<button
onclick={() => handleThemeChange("light")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
'light'
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme === 'light'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
@@ -480,178 +440,17 @@
</button>
<button
onclick={() => handleThemeChange("high-contrast")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
class="flex-1 px-3 py-2 rounded-lg border transition-colors {config.theme ===
'high-contrast'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="High contrast mode for improved accessibility"
>
Contrast
</button>
<button
onclick={() => handleThemeChange("custom")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
'custom'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Create your own custom theme"
>
Custom
High Contrast
</button>
</div>
</div>
<!-- Custom Theme Editor -->
{#if config.theme === "custom" || showCustomThemeEditor}
<div class="mb-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">Custom Theme Colors</h4>
<div class="grid grid-cols-2 gap-3">
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-primary"
>Background</label
>
<div class="flex gap-2 items-center">
<input
id="color-bg-primary"
type="color"
value={config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
oninput={(e) => handleCustomColorChange("bg_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-secondary"
>Secondary BG</label
>
<div class="flex gap-2 items-center">
<input
id="color-bg-secondary"
type="color"
value={config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
oninput={(e) => handleCustomColorChange("bg_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-terminal"
>Terminal BG</label
>
<div class="flex gap-2 items-center">
<input
id="color-bg-terminal"
type="color"
value={config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
oninput={(e) => handleCustomColorChange("bg_terminal", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-border">Border</label>
<div class="flex gap-2 items-center">
<input
id="color-border"
type="color"
value={config.custom_theme_colors.border_color || defaultDarkColors.border_color}
oninput={(e) => handleCustomColorChange("border_color", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.border_color || defaultDarkColors.border_color}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-primary"
>Accent Primary</label
>
<div class="flex gap-2 items-center">
<input
id="color-accent-primary"
type="color"
value={config.custom_theme_colors.accent_primary ||
defaultDarkColors.accent_primary}
oninput={(e) => handleCustomColorChange("accent_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.accent_primary || defaultDarkColors.accent_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-secondary"
>Accent Secondary</label
>
<div class="flex gap-2 items-center">
<input
id="color-accent-secondary"
type="color"
value={config.custom_theme_colors.accent_secondary ||
defaultDarkColors.accent_secondary}
oninput={(e) =>
handleCustomColorChange("accent_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.accent_secondary ||
defaultDarkColors.accent_secondary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-text-primary"
>Text Primary</label
>
<div class="flex gap-2 items-center">
<input
id="color-text-primary"
type="color"
value={config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
oninput={(e) => handleCustomColorChange("text_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-text-secondary"
>Text Secondary</label
>
<div class="flex gap-2 items-center">
<input
id="color-text-secondary"
type="color"
value={config.custom_theme_colors.text_secondary ||
defaultDarkColors.text_secondary}
oninput={(e) => handleCustomColorChange("text_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.text_secondary || defaultDarkColors.text_secondary}
</span>
</div>
</div>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-3">
Changes preview live. Click Save Settings to persist.
</p>
</div>
{/if}
<!-- Font Size -->
<div class="mb-4">
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
@@ -865,39 +664,4 @@
background: var(--text-tertiary);
cursor: not-allowed;
}
/* Color picker styling */
.color-input-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.color-picker {
width: 32px;
height: 32px;
padding: 0;
border: 2px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
background: transparent;
}
.color-picker::-webkit-color-swatch-wrapper {
padding: 2px;
}
.color-picker::-webkit-color-swatch {
border-radius: 4px;
border: none;
}
.color-picker::-moz-color-swatch {
border-radius: 4px;
border: none;
}
.color-picker:hover {
border-color: var(--accent-primary);
}
</style>
+6 -16
View File
@@ -279,15 +279,8 @@
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="git-panel-overlay" on:click={onClose} role="presentation">
<div
class="git-panel"
on:click|stopPropagation
role="dialog"
aria-label="Git Panel"
tabindex="-1"
>
<div class="git-panel" on:click|stopPropagation role="dialog" aria-label="Git Panel">
<div class="git-panel-header">
<h2>🔀 Git</h2>
<button class="close-btn" on:click={onClose} title="Close (Esc)">âś•</button>
@@ -587,15 +580,8 @@
</div>
{#if showDiff}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="diff-overlay" on:click={() => (showDiff = false)} role="presentation">
<div
class="diff-modal"
on:click|stopPropagation
role="dialog"
aria-label="Diff View"
tabindex="-1"
>
<div class="diff-modal" on:click|stopPropagation role="dialog" aria-label="Diff View">
<div class="diff-header">
<h3>đź“„ {diffFile}</h3>
<button on:click={() => (showDiff = false)} title="Close">âś•</button>
@@ -952,6 +938,10 @@
cursor: pointer;
}
.new-branch-input button:first-of-type {
/* Styling handled by btn-trans-gradient class */
}
.new-branch-input button:last-of-type {
background: var(--bg-tertiary);
color: var(--text-primary);
-29
View File
@@ -30,7 +30,6 @@
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history";
@@ -50,23 +49,6 @@
let showClipboardHistory = $state(false);
let streamerModeActive = $state(false);
// Context menu state
let textareaElement: HTMLTextAreaElement | undefined = $state();
let contextMenuShow = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
contextMenuShow = true;
contextMenuX = event.clientX;
contextMenuY = event.clientY;
}
function closeContextMenu() {
contextMenuShow = false;
}
isStreamerMode.subscribe((value) => {
streamerModeActive = value;
});
@@ -894,12 +876,10 @@ User: ${formattedMessage}`;
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
<textarea
bind:this={textareaElement}
bind:value={inputValue}
onkeydown={handleKeyDown}
oninput={handleInputChange}
onpaste={handlePaste}
oncontextmenu={handleContextMenu}
placeholder={isConnected
? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."}
@@ -978,15 +958,6 @@ User: ${formattedMessage}`;
/>
{/if}
{#if contextMenuShow && textareaElement}
<TextInputContextMenu
x={contextMenuX}
y={contextMenuY}
inputElement={textareaElement}
onClose={closeContextMenu}
/>
{/if}
<style>
.input-bar {
display: flex;
@@ -12,7 +12,6 @@
{ keys: ["Escape"], description: "Close modals and panels" },
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
{ keys: ["Ctrl", ","], description: "Open settings" },
{ keys: ["Ctrl", "E"], description: "Toggle file editor" },
{ keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" },
{ keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" },
],
@@ -27,17 +26,6 @@
{ keys: ["↓"], description: "Next input from history" },
],
},
{
category: "File Editor",
items: [
{ keys: ["Ctrl", "E"], description: "Toggle editor view" },
{ keys: ["Ctrl", "B"], description: "Toggle file browser" },
{ keys: ["Ctrl", "S"], description: "Save current file" },
{ keys: ["Ctrl", "W"], description: "Close current tab" },
{ keys: ["Ctrl", "N"], description: "New file" },
{ keys: ["Right-click"], description: "Context menu (New/Delete)" },
],
},
{
category: "Slash Commands",
items: [
+29 -125
View File
@@ -39,12 +39,11 @@
let isGeneratingImage = false;
$: unlockedCount = Object.values($achievementsStore.achievements).filter(
(a) => a.unlocked
).length;
$: unlockedCount = Object.values($achievementsStore.achievements).filter((a) => a.unlocked).length;
$: totalAchievements = Object.values($achievementsStore.achievements).length;
$: achievementPercentage =
totalAchievements > 0 ? Math.round((unlockedCount / totalAchievements) * 100) : 0;
$: achievementPercentage = totalAchievements > 0
? Math.round((unlockedCount / totalAchievements) * 100)
: 0;
async function selectAvatar() {
try {
@@ -83,15 +82,11 @@
async function loadAvatarAsDataUrl(path: string): Promise<string | null> {
try {
const data = await readFile(path);
const extension = path.split(".").pop()?.toLowerCase() || "png";
const mimeType =
extension === "jpg" || extension === "jpeg"
? "image/jpeg"
: extension === "gif"
? "image/gif"
: extension === "webp"
? "image/webp"
: "image/png";
const extension = path.split('.').pop()?.toLowerCase() || 'png';
const mimeType = extension === 'jpg' || extension === 'jpeg' ? 'image/jpeg'
: extension === 'gif' ? 'image/gif'
: extension === 'webp' ? 'image/webp'
: 'image/png';
// Convert Uint8Array to base64 in chunks to avoid stack overflow
const blob = new Blob([data], { type: mimeType });
return new Promise((resolve) => {
@@ -128,7 +123,7 @@
const stripeColors = ["#5bcefa", "#f5a9b8", "#ffffff", "#f5a9b8", "#5bcefa"];
stripeColors.forEach((color, i) => {
ctx.fillStyle = color;
ctx.fillRect(0, i * (stripeHeight / 5) * 2, width, (stripeHeight / 5) * 2);
ctx.fillRect(0, i * (stripeHeight / 5) * 2, width, stripeHeight / 5 * 2);
});
// Border
@@ -159,13 +154,7 @@
img.onerror = () => reject();
img.src = dataUrl;
});
ctx.drawImage(
img,
avatarX - avatarRadius,
avatarY - avatarRadius,
avatarRadius * 2,
avatarRadius * 2
);
ctx.drawImage(img, avatarX - avatarRadius, avatarY - avatarRadius, avatarRadius * 2, avatarRadius * 2);
avatarLoaded = true;
}
} catch {
@@ -174,22 +163,12 @@
}
if (!avatarLoaded) {
const avatarGradient = ctx.createLinearGradient(
avatarX - avatarRadius,
avatarY - avatarRadius,
avatarX + avatarRadius,
avatarY + avatarRadius
);
const avatarGradient = ctx.createLinearGradient(avatarX - avatarRadius, avatarY - avatarRadius, avatarX + avatarRadius, avatarY + avatarRadius);
avatarGradient.addColorStop(0, "#5bcefa");
avatarGradient.addColorStop(0.5, "#f5a9b8");
avatarGradient.addColorStop(1, "#5bcefa");
ctx.fillStyle = avatarGradient;
ctx.fillRect(
avatarX - avatarRadius,
avatarY - avatarRadius,
avatarRadius * 2,
avatarRadius * 2
);
ctx.fillRect(avatarX - avatarRadius, avatarY - avatarRadius, avatarRadius * 2, avatarRadius * 2);
}
ctx.restore();
@@ -281,23 +260,13 @@
progressGradient.addColorStop(1, "#5bcefa");
ctx.fillStyle = progressGradient;
ctx.beginPath();
ctx.roundRect(
progressX,
progressY,
progressWidth * (achievementPercentage / 100),
progressHeight,
22
);
ctx.roundRect(progressX, progressY, progressWidth * (achievementPercentage / 100), progressHeight, 22);
ctx.fill();
// Progress text
ctx.fillStyle = "#ffffff";
ctx.font = "bold 36px system-ui, -apple-system, sans-serif";
ctx.fillText(
`${unlockedCount} / ${totalAchievements} (${achievementPercentage}%)`,
progressX + progressWidth + 40,
progressY + 34
);
ctx.fillText(`${unlockedCount} / ${totalAchievements} (${achievementPercentage}%)`, progressX + progressWidth + 40, progressY + 34);
// Hikari branding
ctx.fillStyle = "#f5a9b8";
@@ -343,14 +312,7 @@
<div class="profile-header">
<h2>Profile</h2>
<button class="close-btn" on:click={onClose} aria-label="Close profile">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
@@ -359,13 +321,7 @@
<div class="profile-content">
<!-- Avatar Section -->
<div class="avatar-section">
<div
class="avatar-container"
on:click={selectAvatar}
role="button"
tabindex="0"
on:keydown={(e) => e.key === "Enter" && selectAvatar()}
>
<div class="avatar-container" on:click={selectAvatar} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && selectAvatar()}>
{#if avatarDataUrl}
<img src={avatarDataUrl} alt="Profile avatar" class="avatar-image" />
<div class="avatar-overlay">
@@ -373,14 +329,7 @@
</div>
{:else}
<div class="avatar-placeholder">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
@@ -401,26 +350,13 @@
bind:value={nameInput}
placeholder="Enter your name"
class="name-input"
on:keydown={(e) => e.key === "Enter" && saveName()}
on:keydown={(e) => e.key === 'Enter' && saveName()}
on:blur={saveName}
/>
{:else}
<button
class="name-display"
on:click={() => {
editingName = true;
nameInput = config.profile_name || "";
}}
>
<button class="name-display" on:click={() => { editingName = true; nameInput = config.profile_name || ""; }}>
<span class="name-text">{config.profile_name || "Click to add name"}</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
@@ -441,13 +377,7 @@
></textarea>
<button class="save-bio-btn btn-trans-gradient" on:click={saveBio}>Save</button>
{:else}
<button
class="bio-display"
on:click={() => {
editingBio = true;
bioInput = config.profile_bio || "";
}}
>
<button class="bio-display" on:click={() => { editingBio = true; bioInput = config.profile_bio || ""; }}>
<span class="bio-text">{config.profile_bio || "Click to add a bio..."}</span>
</button>
{/if}
@@ -491,31 +421,18 @@
<div class="progress-bar">
<div class="progress-fill" style="width: {achievementPercentage}%"></div>
</div>
<span class="progress-text"
>{unlockedCount} / {totalAchievements} ({achievementPercentage}%)</span
>
<span class="progress-text">{unlockedCount} / {totalAchievements} ({achievementPercentage}%)</span>
</div>
</div>
<!-- Share Section -->
<div class="share-section">
<button
class="share-btn btn-trans-gradient"
on:click={shareProfile}
disabled={isGeneratingImage}
>
<button class="share-btn btn-trans-gradient" on:click={shareProfile} disabled={isGeneratingImage}>
{#if isGeneratingImage}
<span class="spinner"></span>
Generating...
{:else}
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
@@ -844,14 +761,7 @@
.progress-fill {
height: 100%;
background: linear-gradient(
90deg,
var(--trans-blue),
var(--trans-pink),
var(--trans-white),
var(--trans-pink),
var(--trans-blue)
);
background: linear-gradient(90deg, var(--trans-blue), var(--trans-pink), var(--trans-white), var(--trans-pink), var(--trans-blue));
background-size: 200% 100%;
animation: shimmer 3s linear infinite;
border-radius: 6px;
@@ -859,12 +769,8 @@
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.progress-text {
@@ -922,8 +828,6 @@
}
@keyframes spin {
to {
transform: rotate(360deg);
}
to { transform: rotate(360deg); }
}
</style>
@@ -449,7 +449,6 @@
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -469,7 +469,6 @@
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -460,7 +460,6 @@
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
+8
View File
@@ -119,6 +119,14 @@
opacity: 0.8;
}
.stat-highlight {
font-weight: 600;
color: var(--accent-primary);
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px solid var(--border-color);
}
.stat-label {
color: var(--text-secondary, #9ca3af);
}
-39
View File
@@ -13,7 +13,6 @@
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
import { editorStore } from "$lib/stores/editor";
import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte";
@@ -64,16 +63,6 @@
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
});
let streamerModeActive = $state(false);
@@ -81,15 +70,6 @@
streamerModeActive = value;
});
let editorVisible = $state(false);
editorStore.isEditorVisible.subscribe((value) => {
editorVisible = value;
});
function toggleEditor() {
editorStore.toggleEditor();
}
onMount(async () => {
appVersion = await getVersion();
});
@@ -317,25 +297,6 @@
/>
</svg>
</button>
<button
onclick={toggleEditor}
disabled={connectionStatus !== "connected"}
class="p-1 text-gray-500 icon-trans-hover {editorVisible
? 'text-[var(--trans-pink)]'
: ''} disabled:opacity-40 disabled:cursor-not-allowed"
title={connectionStatus === "connected"
? "File Editor (Ctrl+E)"
: "Connect to enable file editor"}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
+6 -93
View File
@@ -155,30 +155,6 @@
terminalElement.removeEventListener("copy", handleCopy);
}
});
// Copy message content to clipboard
async function copyMessage(content: string) {
try {
await navigator.clipboard.writeText(content);
// Optionally capture to clipboard history
await clipboardStore.captureClipboard(content, null, "Message from chat");
// Visual feedback could be added here if needed
} catch (error) {
console.error("Failed to copy message:", error);
}
}
// State for showing "Copied!" feedback
let copiedMessageId: string | null = null;
async function handleCopyMessage(messageId: string, content: string) {
await copyMessage(content);
copiedMessageId = messageId;
setTimeout(() => {
copiedMessageId = null;
}, 2000);
}
</script>
<div
@@ -209,7 +185,7 @@
</div>
{:else}
{#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
<div class="terminal-line mb-2 {getLineClass(line.type)}">
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
{#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
@@ -217,33 +193,11 @@
{#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant" || line.type === "user"}
<div class="message-content-wrapper">
<Markdown
content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery}
/>
<button
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
onclick={() => handleCopyMessage(line.id, line.content)}
title="Copy message"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button>
</div>
{#if line.type === "assistant"}
<Markdown
content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery}
/>
{:else}
<HighlightedText
content={maskPaths(line.content, hidePaths)}
@@ -313,45 +267,4 @@
border-radius: 2px;
padding: 0 2px;
}
/* Message content wrapper for positioning */
.message-content-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
/* Copy button styling */
.copy-message-btn {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
gap: 0.4em;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.25em 0.5em;
cursor: pointer;
border-radius: 4px;
font-size: 0.85em;
font-family: inherit;
transition: all 0.15s ease;
}
.copy-message-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-hover, var(--border-color));
}
.copy-message-btn svg {
flex-shrink: 0;
}
/* Ensure relative positioning for parent */
.terminal-line {
position: relative;
}
</style>
@@ -1,228 +0,0 @@
<script lang="ts">
interface Props {
x: number;
y: number;
inputElement: HTMLTextAreaElement | HTMLInputElement;
onClose: () => void;
}
let { x, y, inputElement, onClose }: Props = $props();
// Menu element reference for measuring
let menuElement: HTMLDivElement | undefined = $state();
// Adjusted position to keep menu within viewport
let adjustedX = $derived.by(() => {
if (!menuElement) return x;
const menuWidth = menuElement.offsetWidth || 180;
const maxX = window.innerWidth - menuWidth - 8;
return Math.min(x, maxX);
});
let adjustedY = $derived.by(() => {
if (!menuElement) return y;
const menuHeight = menuElement.offsetHeight || 250;
const maxY = window.innerHeight - menuHeight - 8;
return Math.min(y, maxY);
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
function execCommand(command: "cut" | "copy" | "paste" | "selectAll" | "undo" | "redo") {
inputElement.focus();
switch (command) {
case "cut":
document.execCommand("cut");
break;
case "copy":
document.execCommand("copy");
break;
case "paste":
document.execCommand("paste");
break;
case "selectAll":
inputElement.select();
break;
case "undo":
document.execCommand("undo");
break;
case "redo":
document.execCommand("redo");
break;
}
onClose();
}
// Check if there's a selection
let hasSelection = $derived(inputElement.selectionStart !== inputElement.selectionEnd);
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="menu-overlay"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={menuElement}
class="menu-content"
style="left: {adjustedX}px; top: {adjustedY}px;"
onclick={(e) => e.stopPropagation()}
>
<button class="menu-item" onclick={() => execCommand("undo")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 10h10a5 5 0 0 1 5 5v2M3 10l4-4M3 10l4 4"
/>
</svg>
Undo
<span class="shortcut">Ctrl+Z</span>
</button>
<button class="menu-item" onclick={() => execCommand("redo")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 10H11a5 5 0 0 0-5 5v2M21 10l-4-4M21 10l-4 4"
/>
</svg>
Redo
<span class="shortcut">Ctrl+Y</span>
</button>
<div class="menu-divider"></div>
<button class="menu-item" onclick={() => execCommand("cut")} disabled={!hasSelection}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.121 14.121L19 19m-7-7l7-7m-7 7l-2.879 2.879M12 12L9.121 9.121m0 5.758a3 3 0 1 0-4.243-4.243 3 3 0 0 0 4.243 4.243zm0-5.758a3 3 0 1 0-4.243-4.243 3 3 0 0 0 4.243 4.243z"
/>
</svg>
Cut
<span class="shortcut">Ctrl+X</span>
</button>
<button class="menu-item" onclick={() => execCommand("copy")} disabled={!hasSelection}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2m-6 12h8a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2z"
/>
</svg>
Copy
<span class="shortcut">Ctrl+C</span>
</button>
<button class="menu-item" onclick={() => execCommand("paste")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2"
/>
</svg>
Paste
<span class="shortcut">Ctrl+V</span>
</button>
<div class="menu-divider"></div>
<button class="menu-item" onclick={() => execCommand("selectAll")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5z"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9h6v6H9z" />
</svg>
Select All
<span class="shortcut">Ctrl+A</span>
</button>
</div>
</div>
<style>
.menu-overlay {
position: fixed;
inset: 0;
z-index: 50;
}
.menu-content {
position: absolute;
z-index: 50;
min-width: 180px;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
padding: 0.25rem 0;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
text-align: left;
font-size: 0.875rem;
color: var(--text-primary);
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.menu-item:hover:not(:disabled) {
background-color: var(--bg-primary);
}
.menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-divider {
margin: 0.25rem 0;
border-top: 1px solid var(--border-color);
}
.menu-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.shortcut {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-secondary);
}
</style>
+1 -1
View File
@@ -17,7 +17,7 @@
hasQuestionPending.subscribe((pending) => {
isVisible = pending;
if (!pending) {
selectedOptions.clear();
selectedOptions = new SvelteSet();
customAnswer = "";
showCustomInput = false;
}
-482
View File
@@ -1,482 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { EditorView, basicSetup } from "codemirror";
import { EditorState, Compartment } from "@codemirror/state";
import { keymap } from "@codemirror/view";
import { oneDark } from "@codemirror/theme-one-dark";
import { javascript } from "@codemirror/lang-javascript";
import { python } from "@codemirror/lang-python";
import { rust } from "@codemirror/lang-rust";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { markdown } from "@codemirror/lang-markdown";
import { xml } from "@codemirror/lang-xml";
import { sql } from "@codemirror/lang-sql";
import { java } from "@codemirror/lang-java";
import { cpp } from "@codemirror/lang-cpp";
import { php } from "@codemirror/lang-php";
import { go } from "@codemirror/lang-go";
import { yaml } from "@codemirror/lang-yaml";
import { sass } from "@codemirror/lang-sass";
import { less } from "@codemirror/lang-less";
import { vue } from "@codemirror/lang-vue";
import { angular } from "@codemirror/lang-angular";
import { wast } from "@codemirror/lang-wast";
import { StreamLanguage } from "@codemirror/language";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
import { swift } from "@codemirror/legacy-modes/mode/swift";
import { lua } from "@codemirror/legacy-modes/mode/lua";
import { r } from "@codemirror/legacy-modes/mode/r";
import { toml } from "@codemirror/legacy-modes/mode/toml";
import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile";
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
import { editorStore } from "$lib/stores/editor";
import { configStore } from "$lib/stores/config";
import EditorContextMenu from "./EditorContextMenu.svelte";
import type { EditorTab } from "$lib/types/editor";
import type { Extension } from "@codemirror/state";
export let tab: EditorTab;
let editorContainer: HTMLDivElement;
let view: EditorView | null = null;
let themeCompartment = new Compartment();
// Context menu state
let contextMenuShow = false;
let contextMenuX = 0;
let contextMenuY = 0;
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
contextMenuShow = true;
contextMenuX = event.clientX;
contextMenuY = event.clientY;
}
function closeContextMenu() {
contextMenuShow = false;
}
// Subscribe to theme changes
const config = configStore.config;
// Light theme
const lightTheme = EditorView.theme(
{
"&": {
backgroundColor: "#ffffff",
color: "#24292e",
},
".cm-content": {
caretColor: "#24292e",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "#24292e",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "#c8d3f5",
},
".cm-panels": {
backgroundColor: "#f6f8fa",
color: "#24292e",
},
".cm-panels.cm-panels-top": {
borderBottom: "1px solid #e1e4e8",
},
".cm-panels.cm-panels-bottom": {
borderTop: "1px solid #e1e4e8",
},
".cm-searchMatch": {
backgroundColor: "#ffdf5d",
outline: "1px solid #c4a000",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "#c4a000",
},
".cm-activeLine": {
backgroundColor: "#f6f8fa",
},
".cm-selectionMatch": {
backgroundColor: "#c8d3f5",
},
".cm-matchingBracket, .cm-nonmatchingBracket": {
backgroundColor: "#c8d3f5",
outline: "1px solid #888",
},
".cm-gutters": {
backgroundColor: "#f6f8fa",
color: "#6a737d",
border: "none",
borderRight: "1px solid #e1e4e8",
},
".cm-activeLineGutter": {
backgroundColor: "#e1e4e8",
},
".cm-foldPlaceholder": {
backgroundColor: "transparent",
border: "none",
color: "#6a737d",
},
".cm-tooltip": {
border: "1px solid #e1e4e8",
backgroundColor: "#ffffff",
},
".cm-tooltip .cm-tooltip-arrow:before": {
borderTopColor: "transparent",
borderBottomColor: "transparent",
},
".cm-tooltip .cm-tooltip-arrow:after": {
borderTopColor: "#ffffff",
borderBottomColor: "#ffffff",
},
".cm-tooltip-autocomplete": {
"& > ul > li[aria-selected]": {
backgroundColor: "#e1e4e8",
color: "#24292e",
},
},
},
{ dark: false }
);
const lightHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "#d73a49" },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
color: "#6f42c1",
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: "#6f42c1" },
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: "#005cc5" },
{ tag: [tags.definition(tags.name), tags.separator], color: "#24292e" },
{
tag: [
tags.typeName,
tags.className,
tags.number,
tags.changed,
tags.annotation,
tags.modifier,
tags.self,
tags.namespace,
],
color: "#e36209",
},
{
tag: [
tags.operator,
tags.operatorKeyword,
tags.url,
tags.escape,
tags.regexp,
tags.link,
tags.special(tags.string),
],
color: "#032f62",
},
{ tag: [tags.meta, tags.comment], color: "#6a737d" },
{ tag: tags.strong, fontWeight: "bold" },
{ tag: tags.emphasis, fontStyle: "italic" },
{ tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: tags.link, color: "#032f62", textDecoration: "underline" },
{ tag: tags.heading, fontWeight: "bold", color: "#005cc5" },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: "#005cc5" },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: "#22863a" },
{ tag: tags.invalid, color: "#cb2431" },
]);
// High contrast theme
const highContrastTheme = EditorView.theme(
{
"&": {
backgroundColor: "#000000",
color: "#ffffff",
},
".cm-content": {
caretColor: "#ffffff",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "#ffffff",
borderLeftWidth: "2px",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "#264f78",
},
".cm-panels": {
backgroundColor: "#000000",
color: "#ffffff",
},
".cm-panels.cm-panels-top": {
borderBottom: "2px solid #ffffff",
},
".cm-panels.cm-panels-bottom": {
borderTop: "2px solid #ffffff",
},
".cm-searchMatch": {
backgroundColor: "#515c6a",
outline: "2px solid #ffff00",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "#ffff00",
color: "#000000",
},
".cm-activeLine": {
backgroundColor: "#1a1a1a",
},
".cm-selectionMatch": {
backgroundColor: "#264f78",
},
".cm-matchingBracket, .cm-nonmatchingBracket": {
backgroundColor: "#515c6a",
outline: "2px solid #ffff00",
},
".cm-gutters": {
backgroundColor: "#000000",
color: "#858585",
border: "none",
borderRight: "2px solid #ffffff",
},
".cm-activeLineGutter": {
backgroundColor: "#1a1a1a",
color: "#ffffff",
},
".cm-foldPlaceholder": {
backgroundColor: "transparent",
border: "none",
color: "#ffff00",
},
".cm-tooltip": {
border: "2px solid #ffffff",
backgroundColor: "#000000",
},
".cm-tooltip .cm-tooltip-arrow:before": {
borderTopColor: "transparent",
borderBottomColor: "transparent",
},
".cm-tooltip .cm-tooltip-arrow:after": {
borderTopColor: "#000000",
borderBottomColor: "#000000",
},
".cm-tooltip-autocomplete": {
"& > ul > li[aria-selected]": {
backgroundColor: "#264f78",
color: "#ffffff",
},
},
},
{ dark: true }
);
const highContrastHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "#569cd6", fontWeight: "bold" },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
color: "#9cdcfe",
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: "#dcdcaa" },
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: "#4fc1ff" },
{ tag: [tags.definition(tags.name), tags.separator], color: "#ffffff" },
{
tag: [
tags.typeName,
tags.className,
tags.number,
tags.changed,
tags.annotation,
tags.modifier,
tags.self,
tags.namespace,
],
color: "#4ec9b0",
},
{
tag: [
tags.operator,
tags.operatorKeyword,
tags.url,
tags.escape,
tags.regexp,
tags.link,
tags.special(tags.string),
],
color: "#d4d4d4",
},
{ tag: [tags.meta, tags.comment], color: "#6a9955" },
{ tag: tags.strong, fontWeight: "bold" },
{ tag: tags.emphasis, fontStyle: "italic" },
{ tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: tags.link, color: "#3794ff", textDecoration: "underline" },
{ tag: tags.heading, fontWeight: "bold", color: "#569cd6" },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: "#569cd6" },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: "#ce9178" },
{ tag: tags.invalid, color: "#f44747" },
]);
function getThemeExtension(theme: string): Extension {
switch (theme) {
case "light":
return [lightTheme, syntaxHighlighting(lightHighlightStyle)];
case "high-contrast":
return [highContrastTheme, syntaxHighlighting(highContrastHighlightStyle)];
case "dark":
case "custom":
default:
return oneDark;
}
}
function getLanguageExtension(language: string): Extension {
const languageMap: Record<string, () => Extension> = {
javascript: () => javascript({ jsx: true, typescript: false }),
typescript: () => javascript({ jsx: true, typescript: true }),
python: () => python(),
rust: () => rust(),
html: () => html(),
css: () => css(),
json: () => json(),
markdown: () => markdown(),
xml: () => xml(),
sql: () => sql(),
java: () => java(),
c: () => cpp(),
cpp: () => cpp(),
csharp: () => cpp(),
php: () => php(),
go: () => go(),
yaml: () => yaml(),
scss: () => sass(),
sass: () => sass(),
less: () => less(),
vue: () => vue(),
angular: () => angular(),
wasm: () => wast(),
shell: () => StreamLanguage.define(shell),
ruby: () => StreamLanguage.define(ruby),
swift: () => StreamLanguage.define(swift),
lua: () => StreamLanguage.define(lua),
r: () => StreamLanguage.define(r),
toml: () => StreamLanguage.define(toml),
dockerfile: () => StreamLanguage.define(dockerFile),
powershell: () => StreamLanguage.define(powerShell),
svelte: () => html(),
};
const getExtension = languageMap[language];
return getExtension ? getExtension() : [];
}
function createEditor() {
if (!editorContainer) return;
const currentTheme = $config.theme;
const saveKeymap = keymap.of([
{
key: "Mod-s",
run: () => {
editorStore.saveFile(tab.id);
return true;
},
},
]);
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
const content = update.state.doc.toString();
editorStore.updateTabContent(tab.id, content);
}
});
const state = EditorState.create({
doc: tab.content,
extensions: [
basicSetup,
themeCompartment.of(getThemeExtension(currentTheme)),
getLanguageExtension(tab.language),
saveKeymap,
updateListener,
EditorView.theme({
"&": {
height: "100%",
fontSize: "14px",
},
".cm-scroller": {
overflow: "auto",
},
".cm-content": {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
},
}),
],
});
view = new EditorView({
state,
parent: editorContainer,
});
}
function destroyEditor() {
if (view) {
view.destroy();
view = null;
}
}
// Watch for theme changes and update the editor
$: if (view && $config.theme) {
view.dispatch({
effects: themeCompartment.reconfigure(getThemeExtension($config.theme)),
});
}
onMount(() => {
createEditor();
});
onDestroy(() => {
destroyEditor();
});
$: if (view && tab.content !== view.state.doc.toString()) {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: tab.content,
},
});
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-editor" bind:this={editorContainer} oncontextmenu={handleContextMenu}></div>
{#if contextMenuShow && view}
<EditorContextMenu
x={contextMenuX}
y={contextMenuY}
editorView={view}
onClose={closeContextMenu}
/>
{/if}
<style>
.code-editor {
flex: 1;
overflow: hidden;
background-color: var(--bg-terminal);
}
.code-editor :global(.cm-editor) {
height: 100%;
}
.code-editor :global(.cm-focused) {
outline: none;
}
</style>
@@ -1,131 +0,0 @@
<script lang="ts">
interface Props {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
let {
title,
message,
confirmText = "Confirm",
cancelText = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onCancel();
} else if (event.key === "Enter") {
onConfirm();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-overlay" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
<h2 class="dialog-title">{title}</h2>
<p class="dialog-message">{message}</p>
<div class="dialog-actions">
<button class="btn-cancel" onclick={onCancel}>
{cancelText}
</button>
<button class="btn-confirm" class:destructive onclick={onConfirm}>
{confirmText}
</button>
</div>
</div>
</div>
<style>
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-content {
margin: 0 1rem;
width: 100%;
max-width: 28rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
padding: 1.5rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.dialog-title {
margin-bottom: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.dialog-message {
margin-bottom: 1.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn-cancel {
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn-cancel:hover {
background-color: var(--bg-secondary);
}
.btn-confirm {
border-radius: 0.375rem;
border: none;
background-color: var(--accent-primary);
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: white;
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn-confirm:hover {
background-color: var(--accent-secondary);
}
.btn-confirm.destructive {
background-color: #dc2626;
}
.btn-confirm.destructive:hover {
background-color: #b91c1c;
}
</style>
@@ -1,238 +0,0 @@
<script lang="ts">
import type { EditorView } from "@codemirror/view";
interface Props {
x: number;
y: number;
editorView: EditorView;
onClose: () => void;
}
let { x, y, editorView, onClose }: Props = $props();
// Menu element reference for measuring
let menuElement: HTMLDivElement | undefined = $state();
// Adjusted position to keep menu within viewport
let adjustedX = $derived.by(() => {
if (!menuElement) return x;
const menuWidth = menuElement.offsetWidth || 180;
const maxX = window.innerWidth - menuWidth - 8;
return Math.min(x, maxX);
});
let adjustedY = $derived.by(() => {
if (!menuElement) return y;
const menuHeight = menuElement.offsetHeight || 250;
const maxY = window.innerHeight - menuHeight - 8;
return Math.min(y, maxY);
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
function execCommand(command: "cut" | "copy" | "paste" | "selectAll" | "undo" | "redo") {
editorView.focus();
switch (command) {
case "cut":
document.execCommand("cut");
break;
case "copy":
document.execCommand("copy");
break;
case "paste":
document.execCommand("paste");
break;
case "selectAll":
editorView.dispatch({
selection: { anchor: 0, head: editorView.state.doc.length },
});
break;
case "undo":
import("@codemirror/commands").then(({ undo }) => {
undo(editorView);
});
break;
case "redo":
import("@codemirror/commands").then(({ redo }) => {
redo(editorView);
});
break;
}
onClose();
}
// Check if there's a selection
let hasSelection = $derived(
editorView.state.selection.main.from !== editorView.state.selection.main.to
);
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="menu-overlay"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={menuElement}
class="menu-content"
style="left: {adjustedX}px; top: {adjustedY}px;"
onclick={(e) => e.stopPropagation()}
>
<button class="menu-item" onclick={() => execCommand("undo")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 10h10a5 5 0 0 1 5 5v2M3 10l4-4M3 10l4 4"
/>
</svg>
Undo
<span class="shortcut">Ctrl+Z</span>
</button>
<button class="menu-item" onclick={() => execCommand("redo")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 10H11a5 5 0 0 0-5 5v2M21 10l-4-4M21 10l-4 4"
/>
</svg>
Redo
<span class="shortcut">Ctrl+Y</span>
</button>
<div class="menu-divider"></div>
<button class="menu-item" onclick={() => execCommand("cut")} disabled={!hasSelection}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.121 14.121L19 19m-7-7l7-7m-7 7l-2.879 2.879M12 12L9.121 9.121m0 5.758a3 3 0 1 0-4.243-4.243 3 3 0 0 0 4.243 4.243zm0-5.758a3 3 0 1 0-4.243-4.243 3 3 0 0 0 4.243 4.243z"
/>
</svg>
Cut
<span class="shortcut">Ctrl+X</span>
</button>
<button class="menu-item" onclick={() => execCommand("copy")} disabled={!hasSelection}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2m-6 12h8a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2z"
/>
</svg>
Copy
<span class="shortcut">Ctrl+C</span>
</button>
<button class="menu-item" onclick={() => execCommand("paste")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2"
/>
</svg>
Paste
<span class="shortcut">Ctrl+V</span>
</button>
<div class="menu-divider"></div>
<button class="menu-item" onclick={() => execCommand("selectAll")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5z"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9h6v6H9z" />
</svg>
Select All
<span class="shortcut">Ctrl+A</span>
</button>
</div>
</div>
<style>
.menu-overlay {
position: fixed;
inset: 0;
z-index: 50;
}
.menu-content {
position: absolute;
z-index: 50;
min-width: 180px;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
padding: 0.25rem 0;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
text-align: left;
font-size: 0.875rem;
color: var(--text-primary);
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.menu-item:hover:not(:disabled) {
background-color: var(--bg-primary);
}
.menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-divider {
margin: 0.25rem 0;
border-top: 1px solid var(--border-color);
}
.menu-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.shortcut {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-secondary);
}
</style>
@@ -1,253 +0,0 @@
<script lang="ts">
import { editorStore } from "$lib/stores/editor";
import FileBrowser from "./FileBrowser.svelte";
import EditorTabs from "./EditorTabs.svelte";
import CodeEditor from "./CodeEditor.svelte";
const isFileBrowserOpen = editorStore.isFileBrowserOpen;
const activeTab = editorStore.activeTab;
const saveError = editorStore.saveError;
function toggleFileBrowser() {
editorStore.toggleFileBrowser();
}
async function handleSave() {
try {
await editorStore.saveFile();
} catch {
// Error is already set in the store
}
}
</script>
<div class="editor-panel">
<div class="toolbar">
<button
class="toolbar-button"
class:active={$isFileBrowserOpen}
on:click={toggleFileBrowser}
title="Toggle file browser (Ctrl+B)"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</button>
<div class="toolbar-spacer"></div>
{#if $activeTab}
<button class="toolbar-button" on:click={handleSave} title="Save (Ctrl+S)">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
</button>
{/if}
</div>
{#if $saveError}
<div class="error-banner">
<span>{$saveError}</span>
<button
class="dismiss-button"
on:click={() => {}}
title="Dismiss error"
aria-label="Dismiss error"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/if}
<div class="editor-content">
{#if $isFileBrowserOpen}
<div class="file-browser-container">
<FileBrowser />
</div>
{/if}
<div class="editor-main">
<EditorTabs />
<div class="editor-area">
{#if $activeTab}
{#key $activeTab.id}
<CodeEditor tab={$activeTab} />
{/key}
{:else}
<div class="no-file">
<div class="no-file-content">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<p>Select a file to edit</p>
<p class="hint">Use the file browser on the left or press Ctrl+B to toggle it</p>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.editor-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
}
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
}
.toolbar-button:hover {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.toolbar-button.active {
background-color: var(--bg-primary);
color: var(--accent-primary);
}
.toolbar-spacer {
flex: 1;
}
.error-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #ff000022;
border-bottom: 1px solid #ff0000;
color: #ff6b6b;
font-size: 13px;
}
.dismiss-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: none;
color: #ff6b6b;
cursor: pointer;
border-radius: 4px;
}
.dismiss-button:hover {
background-color: #ff000033;
}
.editor-content {
display: flex;
flex: 1;
overflow: hidden;
}
.file-browser-container {
width: 250px;
min-width: 150px;
max-width: 400px;
flex-shrink: 0;
overflow: hidden;
}
.editor-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-area {
flex: 1;
overflow: hidden;
display: flex;
}
.no-file {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-terminal);
}
.no-file-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--text-secondary);
text-align: center;
}
.no-file-content svg {
opacity: 0.5;
}
.no-file-content p {
margin: 0;
}
.no-file-content .hint {
font-size: 12px;
opacity: 0.7;
}
</style>
-170
View File
@@ -1,170 +0,0 @@
<script lang="ts">
import { editorStore } from "$lib/stores/editor";
const tabs = editorStore.tabs;
const activeTabId = editorStore.activeTabId;
function handleTabClick(tabId: string) {
editorStore.setActiveTab(tabId);
}
function handleCloseTab(event: MouseEvent, tabId: string) {
event.stopPropagation();
editorStore.closeTab(tabId);
}
function handleKeydown(event: KeyboardEvent, tabId: string) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
editorStore.setActiveTab(tabId);
}
}
function handleCloseKeydown(event: KeyboardEvent, tabId: string) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
editorStore.closeTab(tabId);
}
}
</script>
<div class="editor-tabs">
{#if $tabs.length === 0}
<div class="no-tabs">No files open</div>
{:else}
<div class="tabs-container" role="tablist">
{#each $tabs as tab (tab.id)}
<div
class="tab"
class:active={tab.id === $activeTabId}
class:dirty={tab.isDirty}
role="tab"
tabindex="0"
aria-selected={tab.id === $activeTabId}
on:click={() => handleTabClick(tab.id)}
on:keydown={(e) => handleKeydown(e, tab.id)}
title={tab.filePath}
>
<span class="tab-name">
{tab.fileName}
{#if tab.isDirty}
<span class="dirty-indicator">*</span>
{/if}
</span>
<button
class="close-button"
on:click={(e) => handleCloseTab(e, tab.id)}
on:keydown={(e) => handleCloseKeydown(e, tab.id)}
title="Close tab"
aria-label="Close {tab.fileName}"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.editor-tabs {
display: flex;
align-items: center;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
min-height: 36px;
}
.no-tabs {
padding: 8px 12px;
color: var(--text-secondary);
font-size: 13px;
}
.tabs-container {
display: flex;
overflow-x: auto;
scrollbar-width: thin;
}
.tab {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: transparent;
border: none;
border-right: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.tab:hover {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.tab:focus {
outline: 1px solid var(--accent-primary);
outline-offset: -1px;
}
.tab.active {
background-color: var(--bg-terminal);
color: var(--text-primary);
border-bottom: 2px solid var(--accent-primary);
margin-bottom: -1px;
}
.tab-name {
display: flex;
align-items: center;
gap: 2px;
}
.dirty-indicator {
color: var(--accent-primary);
font-weight: bold;
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
opacity: 0;
transition: all 0.15s ease;
}
.tab:hover .close-button,
.tab.active .close-button {
opacity: 1;
}
.close-button:hover {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.tab.dirty .close-button {
opacity: 1;
}
</style>
@@ -1,358 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { editorStore } from "$lib/stores/editor";
import FileTreeItem from "./FileTreeItem.svelte";
import FileContextMenu from "./FileContextMenu.svelte";
import InputDialog from "./InputDialog.svelte";
import ConfirmDialog from "./ConfirmDialog.svelte";
import type { FileEntry } from "$lib/types/editor";
const fileTree = editorStore.fileTree;
const isLoadingTree = editorStore.isLoadingTree;
const currentDirectory = editorStore.currentDirectory;
// Listen for Ctrl+N keyboard shortcut from +page.svelte
function handleNewFileEvent() {
handleNewFile();
}
onMount(() => {
window.addEventListener("editor-new-file", handleNewFileEvent);
});
onDestroy(() => {
window.removeEventListener("editor-new-file", handleNewFileEvent);
});
// Context menu state
let contextMenu = $state<{
show: boolean;
x: number;
y: number;
targetEntry: FileEntry | null;
}>({
show: false,
x: 0,
y: 0,
targetEntry: null,
});
// Dialog state
let dialog = $state<{
type: "newFile" | "newFolder" | "delete" | "rename" | null;
parentPath: string;
targetEntry: FileEntry | null;
}>({
type: null,
parentPath: "",
targetEntry: null,
});
function handleRefresh() {
const dir = $currentDirectory;
if (dir) {
editorStore.initializeFileTree(dir);
}
}
function handleContextMenu(event: MouseEvent, entry: FileEntry | null = null) {
event.preventDefault();
contextMenu = {
show: true,
x: event.clientX,
y: event.clientY,
targetEntry: entry,
};
}
function closeContextMenu() {
contextMenu = { show: false, x: 0, y: 0, targetEntry: null };
}
function openNewFileDialog(parentPath: string) {
dialog = { type: "newFile", parentPath, targetEntry: null };
}
function openNewFolderDialog(parentPath: string) {
dialog = { type: "newFolder", parentPath, targetEntry: null };
}
function openDeleteDialog(entry: FileEntry) {
dialog = { type: "delete", parentPath: "", targetEntry: entry };
}
function openRenameDialog(entry: FileEntry) {
dialog = { type: "rename", parentPath: "", targetEntry: entry };
}
function closeDialog() {
dialog = { type: null, parentPath: "", targetEntry: null };
}
async function handleCreateFile(fileName: string) {
await editorStore.createFile(dialog.parentPath, fileName);
closeDialog();
}
async function handleCreateFolder(folderName: string) {
await editorStore.createDirectory(dialog.parentPath, folderName);
closeDialog();
}
async function handleDelete() {
if (!dialog.targetEntry) return;
if (dialog.targetEntry.isDirectory) {
await editorStore.deleteDirectory(dialog.targetEntry.path);
} else {
await editorStore.deleteFile(dialog.targetEntry.path);
}
closeDialog();
}
async function handleRename(newName: string) {
if (!dialog.targetEntry) return;
await editorStore.renamePath(dialog.targetEntry.path, newName);
closeDialog();
}
function handleNewFile() {
openNewFileDialog($currentDirectory);
}
function handleNewFolder() {
openNewFolderDialog($currentDirectory);
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="file-browser" oncontextmenu={(e) => handleContextMenu(e, null)}>
<div class="header">
<span class="title">Files</span>
<div class="header-buttons">
<button class="header-button" onclick={handleNewFile} title="New File (Ctrl+N)">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M12 18v-6" />
<path d="M9 15h6" />
</svg>
</button>
<button class="header-button" onclick={handleNewFolder} title="New Folder">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<path d="M12 11v6" />
<path d="M9 14h6" />
</svg>
</button>
<button class="header-button" onclick={handleRefresh} title="Refresh file tree">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M23 4v6h-6" />
<path d="M1 20v-6h6" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
</button>
</div>
</div>
<div class="tree-container">
{#if $isLoadingTree}
<div class="loading">
<svg
class="spinner"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="32">
<animate
attributeName="stroke-dashoffset"
dur="1s"
values="32;0"
repeatCount="indefinite"
/>
</circle>
</svg>
<span>Loading...</span>
</div>
{:else if $fileTree.length === 0}
<div class="empty">
<span>No files found</span>
</div>
{:else}
<div class="tree">
{#each $fileTree as entry (entry.path)}
<FileTreeItem {entry} onContextMenu={handleContextMenu} />
{/each}
</div>
{/if}
</div>
</div>
{#if contextMenu.show}
<FileContextMenu
x={contextMenu.x}
y={contextMenu.y}
targetEntry={contextMenu.targetEntry}
currentDirectory={$currentDirectory}
onNewFile={openNewFileDialog}
onNewFolder={openNewFolderDialog}
onRename={openRenameDialog}
onDelete={openDeleteDialog}
onClose={closeContextMenu}
/>
{/if}
{#if dialog.type === "newFile"}
<InputDialog
title="New File"
placeholder="Enter file name..."
confirmText="Create"
onConfirm={handleCreateFile}
onCancel={closeDialog}
/>
{/if}
{#if dialog.type === "newFolder"}
<InputDialog
title="New Folder"
placeholder="Enter folder name..."
confirmText="Create"
onConfirm={handleCreateFolder}
onCancel={closeDialog}
/>
{/if}
{#if dialog.type === "delete" && dialog.targetEntry}
<ConfirmDialog
title="Delete {dialog.targetEntry.isDirectory ? 'Folder' : 'File'}"
message="Are you sure you want to delete '{dialog.targetEntry.name}'? {dialog.targetEntry
.isDirectory
? 'This will also delete all files and folders inside it.'
: 'This action cannot be undone.'}"
confirmText="Delete"
destructive={true}
onConfirm={handleDelete}
onCancel={closeDialog}
/>
{/if}
{#if dialog.type === "rename" && dialog.targetEntry}
<InputDialog
title="Rename {dialog.targetEntry.isDirectory ? 'Folder' : 'File'}"
placeholder="Enter new name..."
confirmText="Rename"
initialValue={dialog.targetEntry.name}
onConfirm={handleRename}
onCancel={closeDialog}
/>
{/if}
<style>
.file-browser {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
border-right: 1px solid var(--border-color);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
}
.title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.5px;
}
.header-buttons {
display: flex;
gap: 4px;
}
.header-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
}
.header-button:hover {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.tree-container {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.tree {
min-width: max-content;
}
.loading,
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: var(--text-secondary);
font-size: 13px;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -1,203 +0,0 @@
<script lang="ts">
import type { FileEntry } from "$lib/types/editor";
interface Props {
x: number;
y: number;
targetEntry: FileEntry | null;
currentDirectory: string;
onNewFile: (parentPath: string) => void;
onNewFolder: (parentPath: string) => void;
onRename: (entry: FileEntry) => void;
onDelete: (entry: FileEntry) => void;
onClose: () => void;
}
let {
x,
y,
targetEntry,
currentDirectory,
onNewFile,
onNewFolder,
onRename,
onDelete,
onClose,
}: Props = $props();
function handleNewFile() {
const parentPath = targetEntry?.isDirectory ? targetEntry.path : currentDirectory;
onNewFile(parentPath);
onClose();
}
function handleNewFolder() {
const parentPath = targetEntry?.isDirectory ? targetEntry.path : currentDirectory;
onNewFolder(parentPath);
onClose();
}
function handleRename() {
if (targetEntry) {
onRename(targetEntry);
onClose();
}
}
function handleDelete() {
if (targetEntry) {
onDelete(targetEntry);
onClose();
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
// Menu element reference for measuring
let menuElement: HTMLDivElement | undefined = $state();
// Adjusted position to keep menu within viewport
let adjustedX = $derived.by(() => {
if (!menuElement) return x;
const menuWidth = menuElement.offsetWidth || 160;
const maxX = window.innerWidth - menuWidth - 8;
return Math.min(x, maxX);
});
let adjustedY = $derived.by(() => {
if (!menuElement) return y;
const menuHeight = menuElement.offsetHeight || 200;
const maxY = window.innerHeight - menuHeight - 8;
return Math.min(y, maxY);
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="menu-overlay"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={menuElement}
class="menu-content"
style="left: {adjustedX}px; top: {adjustedY}px;"
onclick={(e) => e.stopPropagation()}
>
<button class="menu-item" onclick={handleNewFile}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
New File
</button>
<button class="menu-item" onclick={handleNewFolder}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
</svg>
New Folder
</button>
{#if targetEntry}
<div class="menu-divider"></div>
<button class="menu-item" onclick={handleRename}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Rename
</button>
<button class="menu-item destructive" onclick={handleDelete}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Delete {targetEntry.isDirectory ? "Folder" : "File"}
</button>
{/if}
</div>
</div>
<style>
.menu-overlay {
position: fixed;
inset: 0;
z-index: 50;
}
.menu-content {
position: absolute;
z-index: 50;
min-width: 160px;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
padding: 0.25rem 0;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
text-align: left;
font-size: 0.875rem;
color: var(--text-primary);
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.menu-item:hover {
background-color: var(--bg-primary);
}
.menu-item.destructive {
color: #ef4444;
}
.menu-divider {
margin: 0.25rem 0;
border-top: 1px solid var(--border-color);
}
.menu-icon {
width: 1rem;
height: 1rem;
}
</style>
@@ -1,208 +0,0 @@
<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>
@@ -1,173 +0,0 @@
<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
@@ -1,329 +0,0 @@
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);
});
});
});
+11 -90
View File
@@ -1502,25 +1502,12 @@ export const achievementCategories = [
{
name: "Token Milestones",
description: "Track your token generation progress",
ids: [
"FirstSteps",
"GrowingStrong",
"BlossomingCoder",
"TokenMaster",
"TokenBillionaire",
"TokenTreasure",
] as AchievementId[],
ids: ["FirstSteps", "GrowingStrong", "BlossomingCoder", "TokenMaster", "TokenBillionaire", "TokenTreasure"] as AchievementId[],
},
{
name: "Code Generation",
description: "Achievements for generating code",
ids: [
"HelloWorld",
"CodeWizard",
"ThousandBlocks",
"CodeFactory",
"CodeEmpire",
] as AchievementId[],
ids: ["HelloWorld", "CodeWizard", "ThousandBlocks", "CodeFactory", "CodeEmpire"] as AchievementId[],
},
{
name: "File Operations",
@@ -1530,13 +1517,7 @@ export const achievementCategories = [
{
name: "Conversations",
description: "Building our relationship through chat",
ids: [
"ConversationStarter",
"ChattyKathy",
"Conversationalist",
"ChatMarathon",
"ChatLegend",
] as AchievementId[],
ids: ["ConversationStarter", "ChattyKathy", "Conversationalist", "ChatMarathon", "ChatLegend"] as AchievementId[],
},
{
name: "Tools & Skills",
@@ -1575,16 +1556,7 @@ export const achievementCategories = [
{
name: "Relationship & Greetings",
description: "Our special moments together",
ids: [
"GoodMorning",
"GoodNight",
"ThankYou",
"LoveYou",
"HelloHikari",
"HowAreYou",
"MissedYou",
"BackAgain",
] as AchievementId[],
ids: ["GoodMorning", "GoodNight", "ThankYou", "LoveYou", "HelloHikari", "HowAreYou", "MissedYou", "BackAgain"] as AchievementId[],
},
{
name: "Personality & Fun",
@@ -1599,16 +1571,7 @@ export const achievementCategories = [
{
name: "Git & Development",
description: "Version control mastery",
ids: [
"GitGuru",
"TestWriter",
"Debugger",
"CommitKing",
"CommitLegend",
"BranchMaster",
"MergeExpert",
"ConflictResolver",
] as AchievementId[],
ids: ["GitGuru", "TestWriter", "Debugger", "CommitKing", "CommitLegend", "BranchMaster", "MergeExpert", "ConflictResolver"] as AchievementId[],
},
{
name: "Tool Mastery",
@@ -1643,41 +1606,17 @@ export const achievementCategories = [
{
name: "Seasonal",
description: "Holiday coding!",
ids: [
"NewYearCoder",
"ValentinesDev",
"SpookyCode",
"HolidayCoder",
"LeapDayCoder",
] as AchievementId[],
ids: ["NewYearCoder", "ValentinesDev", "SpookyCode", "HolidayCoder", "LeapDayCoder"] as AchievementId[],
},
{
name: "Message Content",
description: "How you communicate",
ids: [
"LongMessage",
"NovelWriter",
"ShortAndSweet",
"CodeInMessage",
"MarkdownMaster",
] as AchievementId[],
ids: ["LongMessage", "NovelWriter", "ShortAndSweet", "CodeInMessage", "MarkdownMaster"] as AchievementId[],
},
{
name: "Programming Languages",
description: "Languages you've used",
ids: [
"RustDeveloper",
"PythonDeveloper",
"JavaScriptDev",
"TypeScriptDev",
"GoDeveloper",
"CppDeveloper",
"JavaDeveloper",
"HtmlCssDev",
"SqlDeveloper",
"ShellScripter",
"FullStackDev",
] as AchievementId[],
ids: ["RustDeveloper", "PythonDeveloper", "JavaScriptDev", "TypeScriptDev", "GoDeveloper", "CppDeveloper", "JavaDeveloper", "HtmlCssDev", "SqlDeveloper", "ShellScripter", "FullStackDev"] as AchievementId[],
},
{
name: "Project Types",
@@ -1722,23 +1661,12 @@ export const achievementCategories = [
{
name: "UI Exploration",
description: "Explore Hikari's features",
ids: [
"MultiTasker",
"Minimalist",
"PrivacyFirst",
"ThemeChanger",
"SettingsTweaker",
] as AchievementId[],
ids: ["MultiTasker", "Minimalist", "PrivacyFirst", "ThemeChanger", "SettingsTweaker"] as AchievementId[],
},
{
name: "Achievement Meta",
description: "Achievements about achievements",
ids: [
"AchievementHunter",
"Completionist",
"MasterUnlocker",
"PlatinumStatus",
] as AchievementId[],
ids: ["AchievementHunter", "Completionist", "MasterUnlocker", "PlatinumStatus"] as AchievementId[],
},
{
name: "Clipboard & Snippets",
@@ -1748,14 +1676,7 @@ export const achievementCategories = [
{
name: "Other Features",
description: "Miscellaneous features",
ids: [
"QuickActionUser",
"HistoryBuff",
"Archivist",
"SessionExporter",
"GitPanelUser",
"FeatureExplorer",
] as AchievementId[],
ids: ["QuickActionUser", "HistoryBuff", "Archivist", "SessionExporter", "GitPanelUser", "FeatureExplorer"] as AchievementId[],
},
{
name: "Special",
-480
View File
@@ -1,480 +0,0 @@
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");
});
});
});
+6 -84
View File
@@ -1,18 +1,7 @@
import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export type Theme = "dark" | "light" | "high-contrast" | "custom";
export interface CustomThemeColors {
bg_primary: string | null;
bg_secondary: string | null;
bg_terminal: string | null;
accent_primary: string | null;
accent_secondary: string | null;
text_primary: string | null;
text_secondary: string | null;
border_color: string | null;
}
export type Theme = "dark" | "light" | "high-contrast";
export interface HikariConfig {
model: string | null;
@@ -36,7 +25,6 @@ export interface HikariConfig {
profile_name: string | null;
profile_avatar_path: string | null;
profile_bio: string | null;
custom_theme_colors: CustomThemeColors;
}
const defaultConfig: HikariConfig = {
@@ -61,16 +49,6 @@ const defaultConfig: HikariConfig = {
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,
},
};
function createConfigStore() {
@@ -126,24 +104,9 @@ function createConfigStore() {
closeSidebar: () => isSidebarOpen.set(false),
toggleSidebar: () => isSidebarOpen.update((open) => !open),
setTheme: async (theme: Theme, customColors?: CustomThemeColors) => {
const updates: Partial<HikariConfig> = { theme };
if (customColors) {
updates.custom_theme_colors = customColors;
}
await updateConfig(updates);
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
applyTheme(theme, currentConfig.custom_theme_colors);
},
setCustomThemeColors: async (colors: CustomThemeColors) => {
await updateConfig({ custom_theme_colors: colors });
let currentConfig: HikariConfig = defaultConfig;
config.subscribe((c) => (currentConfig = c))();
if (currentConfig.theme === "custom") {
applyCustomThemeColors(colors);
}
setTheme: async (theme: Theme) => {
await updateConfig({ theme });
applyTheme(theme);
},
setFontSize: async (size: number) => {
@@ -213,53 +176,12 @@ function createConfigStore() {
};
}
export function applyTheme(theme: Theme, customColors?: CustomThemeColors) {
export function applyTheme(theme: Theme) {
if (typeof document !== "undefined") {
// For custom theme, we use dark as the base and override with custom colors
document.documentElement.setAttribute("data-theme", theme === "custom" ? "dark" : theme);
// Clear any previously applied custom colors
clearCustomThemeColors();
// Apply custom colors if theme is custom
if (theme === "custom" && customColors) {
applyCustomThemeColors(customColors);
}
document.documentElement.setAttribute("data-theme", theme);
}
}
export function applyCustomThemeColors(colors: CustomThemeColors) {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (colors.bg_primary) root.style.setProperty("--bg-primary", colors.bg_primary);
if (colors.bg_secondary) root.style.setProperty("--bg-secondary", colors.bg_secondary);
if (colors.bg_terminal) root.style.setProperty("--bg-terminal", colors.bg_terminal);
if (colors.accent_primary) root.style.setProperty("--accent-primary", colors.accent_primary);
if (colors.accent_secondary)
root.style.setProperty("--accent-secondary", colors.accent_secondary);
if (colors.text_primary) root.style.setProperty("--text-primary", colors.text_primary);
if (colors.text_secondary) root.style.setProperty("--text-secondary", colors.text_secondary);
if (colors.border_color) root.style.setProperty("--border-color", colors.border_color);
}
export function clearCustomThemeColors() {
if (typeof document === "undefined") return;
const root = document.documentElement;
const customProperties = [
"--bg-primary",
"--bg-secondary",
"--bg-terminal",
"--accent-primary",
"--accent-secondary",
"--text-primary",
"--text-secondary",
"--border-color",
];
customProperties.forEach((prop) => root.style.removeProperty(prop));
}
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
const DEFAULT_FONT_SIZE = 14;
-525
View File
@@ -1,525 +0,0 @@
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();
});
});

Some files were not shown because too many files have changed in this diff Show More