generated from nhcarrigan/template
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
55ad039451
|
|||
| ea53bf0d4d | |||
| a5fb22bd23 | |||
| c819adc9ea | |||
| 58f53a421b | |||
| acebe590c3 | |||
|
7e45c685d3
|
|||
|
514e137590
|
|||
|
fd3122e080
|
|||
|
66c65a6ab8
|
|||
| 19e28b7ec7 | |||
| 08f7ca2d55 | |||
| c5d1df351c |
@@ -39,7 +39,7 @@ git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git <
|
|||||||
|
|
||||||
## Testing Requirements
|
## Testing Requirements
|
||||||
|
|
||||||
**All changes MUST include tests.** This is non-negotiable — no feature, bug fix, or refactor should be committed without corresponding test coverage. If a change cannot be tested (e.g. pure UI layout, Tauri IPC calls that are impossible to mock), document why in a comment.
|
All new features, fixes, and significant changes should include tests whenever possible:
|
||||||
|
|
||||||
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
|
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
|
||||||
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
|
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
|
||||||
@@ -137,16 +137,16 @@ describe("FeatureName", () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding Tests for All Changes
|
### Adding Tests for New Features
|
||||||
|
|
||||||
Every change — features, bug fixes, refactors — must include tests:
|
When developing new features, always add corresponding tests:
|
||||||
|
|
||||||
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
||||||
2. **During implementation**: Write tests alongside the code
|
2. **During implementation**: Write tests alongside the code
|
||||||
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
||||||
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
||||||
|
|
||||||
**Do not commit changes without tests.** The goal is to maintain near-100% coverage as the codebase grows, so future refactoring can be done with confidence!
|
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
|
||||||
|
|
||||||
## Quality Assurance
|
## Quality Assurance
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.10.0",
|
"version": "1.9.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -1648,7 +1648,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.10.0"
|
version = "1.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.10.0"
|
version = "1.9.0"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+9
-129
@@ -862,26 +862,6 @@ pub async fn read_file_content(path: String) -> Result<String, String> {
|
|||||||
.map_err(|e| format!("Failed to read file: {}", e))
|
.map_err(|e| format!("Failed to read file: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the first `# Heading` from a WSL file path (for Windows).
|
|
||||||
/// Returns `None` if the file cannot be read or has no top-level heading.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn read_wsl_file_first_heading(path: &str) -> Option<String> {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let output = Command::new("wsl")
|
|
||||||
.hide_window()
|
|
||||||
.args(["-e", "bash", "-c", &format!("head -20 '{}'", path)])
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = String::from_utf8_lossy(&output.stdout);
|
|
||||||
extract_first_heading(&content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read file content via WSL (for Windows with WSL paths)
|
/// Read file content via WSL (for Windows with WSL paths)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
||||||
@@ -1373,29 +1353,9 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct MemoryFileInfo {
|
|
||||||
pub path: String,
|
|
||||||
pub heading: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct MemoryFilesResponse {
|
pub struct MemoryFilesResponse {
|
||||||
pub files: Vec<MemoryFileInfo>,
|
pub files: Vec<String>,
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the first `# Heading` from a string of file content.
|
|
||||||
fn extract_first_heading(content: &str) -> Option<String> {
|
|
||||||
content.lines().find_map(|line| {
|
|
||||||
let trimmed = line.trim();
|
|
||||||
if let Some(rest) = trimmed.strip_prefix("# ") {
|
|
||||||
let heading = rest.trim().to_string();
|
|
||||||
if !heading.is_empty() {
|
|
||||||
return Some(heading);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1438,19 +1398,12 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let paths: Vec<String> = stdout
|
let files: Vec<String> = stdout
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|line| !line.trim().is_empty())
|
.filter(|line| !line.trim().is_empty())
|
||||||
.map(|line| line.trim().to_string())
|
.map(|line| line.trim().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Read first heading from each file via WSL
|
|
||||||
let mut files = Vec::new();
|
|
||||||
for path in paths {
|
|
||||||
let heading = read_wsl_file_first_heading(&path);
|
|
||||||
files.push(MemoryFileInfo { path, heading });
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(MemoryFilesResponse { files })
|
Ok(MemoryFilesResponse { files })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,13 +1425,10 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
|||||||
return Ok(MemoryFilesResponse { files: Vec::new() });
|
return Ok(MemoryFilesResponse { files: Vec::new() });
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut memory_paths = Vec::new();
|
let mut memory_files = Vec::new();
|
||||||
|
|
||||||
// Recursively find all memory directories
|
// Recursively find all memory directories
|
||||||
fn find_memory_files(
|
fn find_memory_files(dir: &std::path::Path, files: &mut Vec<String>) -> std::io::Result<()> {
|
||||||
dir: &std::path::Path,
|
|
||||||
files: &mut Vec<String>,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1511,25 +1461,16 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = find_memory_files(&projects_dir, &mut memory_paths) {
|
if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) {
|
||||||
return Err(format!("Failed to list memory files: {}", e));
|
return Err(format!("Failed to list memory files: {}", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort files alphabetically
|
// Sort files alphabetically
|
||||||
memory_paths.sort();
|
memory_files.sort();
|
||||||
|
|
||||||
// Read first heading from each file
|
Ok(MemoryFilesResponse {
|
||||||
let files = memory_paths
|
files: memory_files,
|
||||||
.into_iter()
|
})
|
||||||
.map(|path| {
|
|
||||||
let heading = fs::read_to_string(&path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|content| extract_first_heading(&content));
|
|
||||||
MemoryFileInfo { path, heading }
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(MemoryFilesResponse { files })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -2961,65 +2902,4 @@ gitea: gitea-mcp -t stdio (STDIO) - âś“ Connected"#;
|
|||||||
assert_eq!(servers[0].name, "asana");
|
assert_eq!(servers[0].name, "asana");
|
||||||
assert_eq!(servers[1].name, "gitea");
|
assert_eq!(servers[1].name, "gitea");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== extract_first_heading tests ====================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_first_heading_returns_heading() {
|
|
||||||
let content = "# My Memory File\n\nSome content here.";
|
|
||||||
assert_eq!(
|
|
||||||
extract_first_heading(content),
|
|
||||||
Some("My Memory File".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_first_heading_ignores_non_h1() {
|
|
||||||
let content = "## Section Header\n### Sub-section\nSome content.";
|
|
||||||
assert_eq!(extract_first_heading(content), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_first_heading_finds_first_h1_after_other_lines() {
|
|
||||||
let content = "Some intro text\n\n# The Real Title\n\nMore content.";
|
|
||||||
assert_eq!(
|
|
||||||
extract_first_heading(content),
|
|
||||||
Some("The Real Title".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_first_heading_trims_whitespace() {
|
|
||||||
let content = "# Trimmed Heading \n\nContent.";
|
|
||||||
assert_eq!(
|
|
||||||
extract_first_heading(content),
|
|
||||||
Some("Trimmed Heading".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_first_heading_returns_none_for_empty_content() {
|
|
||||||
assert_eq!(extract_first_heading(""), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_first_heading_returns_none_for_empty_heading() {
|
|
||||||
let content = "# \n\nContent after empty heading.";
|
|
||||||
assert_eq!(extract_first_heading(content), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_first_heading_returns_none_when_no_headings() {
|
|
||||||
let content = "Just some plain text.\nNo headings here at all.";
|
|
||||||
assert_eq!(extract_first_heading(content), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_first_heading_handles_leading_whitespace_on_line() {
|
|
||||||
let content = " # Indented Heading\n\nContent.";
|
|
||||||
assert_eq!(
|
|
||||||
extract_first_heading(content),
|
|
||||||
Some("Indented Heading".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,20 +144,6 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub show_thinking_blocks: bool,
|
pub show_thinking_blocks: bool,
|
||||||
|
|
||||||
// Custom terminal font settings
|
|
||||||
#[serde(default)]
|
|
||||||
pub custom_font_path: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub custom_font_family: Option<String>,
|
|
||||||
|
|
||||||
// Custom UI font settings
|
|
||||||
#[serde(default)]
|
|
||||||
pub custom_ui_font_path: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub custom_ui_font_family: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -197,10 +183,6 @@ impl Default for HikariConfig {
|
|||||||
background_image_path: None,
|
background_image_path: None,
|
||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
show_thinking_blocks: false,
|
show_thinking_blocks: false,
|
||||||
custom_font_path: None,
|
|
||||||
custom_font_family: None,
|
|
||||||
custom_ui_font_path: None,
|
|
||||||
custom_ui_font_family: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,10 +298,6 @@ mod tests {
|
|||||||
assert!(!config.disable_1m_context);
|
assert!(!config.disable_1m_context);
|
||||||
assert!(config.trusted_workspaces.is_empty());
|
assert!(config.trusted_workspaces.is_empty());
|
||||||
assert!(!config.show_thinking_blocks);
|
assert!(!config.show_thinking_blocks);
|
||||||
assert!(config.custom_font_path.is_none());
|
|
||||||
assert!(config.custom_font_family.is_none());
|
|
||||||
assert!(config.custom_ui_font_path.is_none());
|
|
||||||
assert!(config.custom_ui_font_family.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -359,10 +337,6 @@ mod tests {
|
|||||||
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
||||||
background_image_opacity: 0.25,
|
background_image_opacity: 0.25,
|
||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()),
|
|
||||||
custom_font_family: Some("MyFont".to_string()),
|
|
||||||
custom_ui_font_path: None,
|
|
||||||
custom_ui_font_family: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "hikari-desktop",
|
"productName": "hikari-desktop",
|
||||||
"version": "1.10.0",
|
"version": "1.9.0",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
+5
-1
@@ -154,7 +154,11 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
|
font-family:
|
||||||
|
"Segoe UI",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
@@ -714,153 +714,5 @@ describe("slashCommands", () => {
|
|||||||
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/cd success path", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("changes directory and shows success message", async () => {
|
|
||||||
getMock
|
|
||||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
||||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
|
||||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
|
||||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
|
||||||
invokeMock
|
|
||||||
.mockResolvedValueOnce("/new/path") // validate_directory
|
|
||||||
.mockResolvedValueOnce(undefined) // stop_claude
|
|
||||||
.mockResolvedValueOnce(undefined); // start_claude
|
|
||||||
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
const promise = cdCmd.execute("/new/path");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
"Changed directory to: /new/path"
|
|
||||||
);
|
|
||||||
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends context restoration message when conversation history exists", async () => {
|
|
||||||
getMock
|
|
||||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
||||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
|
||||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
|
||||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue(
|
|
||||||
"previous conversation history"
|
|
||||||
);
|
|
||||||
invokeMock
|
|
||||||
.mockResolvedValueOnce("/new/path") // validate_directory
|
|
||||||
.mockResolvedValueOnce(undefined) // stop_claude
|
|
||||||
.mockResolvedValueOnce(undefined) // start_claude
|
|
||||||
.mockResolvedValueOnce(undefined); // send_prompt
|
|
||||||
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
const promise = cdCmd.execute("/new/path");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith(
|
|
||||||
"send_prompt",
|
|
||||||
expect.objectContaining({
|
|
||||||
message: expect.stringContaining("previous conversation history"),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
|
||||||
"system",
|
|
||||||
"Changed directory to: /new/path"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
|
||||||
const activeConv = {
|
|
||||||
name: "Test Conversation",
|
|
||||||
model: "claude-sonnet",
|
|
||||||
startedAt: new Date("2026-03-03T12:00:00Z"),
|
|
||||||
grantedTools: new Set<string>(),
|
|
||||||
};
|
|
||||||
getMock
|
|
||||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
||||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
|
||||||
.mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation)
|
|
||||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
|
||||||
invokeMock
|
|
||||||
.mockResolvedValueOnce("/new/path")
|
|
||||||
.mockResolvedValueOnce(undefined)
|
|
||||||
.mockResolvedValueOnce(undefined);
|
|
||||||
const { updateDiscordRpc } = await import("$lib/tauri");
|
|
||||||
|
|
||||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
|
||||||
const promise = cdCmd.execute("/new/path");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
|
||||||
"Test Conversation",
|
|
||||||
expect.any(String),
|
|
||||||
expect.any(Date)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/new success path", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts a new conversation and shows success message", async () => {
|
|
||||||
getMock
|
|
||||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
|
||||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
|
||||||
invokeMock
|
|
||||||
.mockResolvedValueOnce("/working/dir") // get_working_directory
|
|
||||||
.mockResolvedValueOnce(undefined) // interrupt_claude
|
|
||||||
.mockResolvedValueOnce(undefined); // start_claude
|
|
||||||
|
|
||||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
|
||||||
const promise = newCmd.execute("");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!");
|
|
||||||
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
|
||||||
const activeConv = {
|
|
||||||
name: "My Conv",
|
|
||||||
model: "claude-sonnet",
|
|
||||||
startedAt: new Date("2026-03-03T12:00:00Z"),
|
|
||||||
grantedTools: new Set<string>(["tool1"]),
|
|
||||||
};
|
|
||||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv);
|
|
||||||
invokeMock
|
|
||||||
.mockResolvedValueOnce("/working/dir")
|
|
||||||
.mockResolvedValueOnce(undefined)
|
|
||||||
.mockResolvedValueOnce(undefined);
|
|
||||||
const { updateDiscordRpc } = await import("$lib/tauri");
|
|
||||||
|
|
||||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
|
||||||
const promise = newCmd.execute("");
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
|
||||||
"My Conv",
|
|
||||||
expect.any(String),
|
|
||||||
expect.any(Date)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
type Theme,
|
type Theme,
|
||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
applyCustomFont,
|
|
||||||
applyCustomUiFont,
|
|
||||||
applyCustomThemeColors,
|
applyCustomThemeColors,
|
||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
@@ -62,19 +60,9 @@
|
|||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
|
||||||
custom_font_family: null,
|
|
||||||
custom_ui_font_path: null,
|
|
||||||
custom_ui_font_family: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
let customFontPathInput = $state("");
|
|
||||||
let customFontFamilyInput = $state("");
|
|
||||||
let customFontStatus: string | null = $state(null);
|
|
||||||
let customUiFontPathInput = $state("");
|
|
||||||
let customUiFontFamilyInput = $state("");
|
|
||||||
let customUiFontStatus: string | null = $state(null);
|
|
||||||
|
|
||||||
interface AuthStatus {
|
interface AuthStatus {
|
||||||
is_logged_in: boolean;
|
is_logged_in: boolean;
|
||||||
@@ -100,10 +88,6 @@
|
|||||||
|
|
||||||
configStore.config.subscribe((c) => {
|
configStore.config.subscribe((c) => {
|
||||||
config = { ...c };
|
config = { ...c };
|
||||||
customFontPathInput = c.custom_font_path ?? "";
|
|
||||||
customFontFamilyInput = c.custom_font_family ?? "";
|
|
||||||
customUiFontPathInput = c.custom_ui_font_path ?? "";
|
|
||||||
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.isSidebarOpen.subscribe((open) => {
|
configStore.isSidebarOpen.subscribe((open) => {
|
||||||
@@ -953,130 +937,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Terminal Font -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom Terminal Font</span>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={customFontPathInput}
|
|
||||||
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
|
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={customFontFamilyInput}
|
|
||||||
placeholder="Font family name (e.g. FiraCode)"
|
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
onclick={async () => {
|
|
||||||
customFontStatus = null;
|
|
||||||
try {
|
|
||||||
await configStore.setCustomFont(
|
|
||||||
customFontPathInput || null,
|
|
||||||
customFontFamilyInput || null
|
|
||||||
);
|
|
||||||
customFontStatus = "Font applied!";
|
|
||||||
} catch (e) {
|
|
||||||
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={async () => {
|
|
||||||
customFontStatus = null;
|
|
||||||
customFontPathInput = "";
|
|
||||||
customFontFamilyInput = "";
|
|
||||||
try {
|
|
||||||
await configStore.setCustomFont(null, null);
|
|
||||||
await applyCustomFont(null, null);
|
|
||||||
customFontStatus = "Font reset to default.";
|
|
||||||
} catch (e) {
|
|
||||||
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if customFontStatus}
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">{customFontStatus}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
|
||||||
Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is
|
|
||||||
required to apply the font.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom UI Font -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom UI Font</span>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={customUiFontPathInput}
|
|
||||||
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
|
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={customUiFontFamilyInput}
|
|
||||||
placeholder="Font family name (e.g. Inter)"
|
|
||||||
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
|
||||||
/>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
onclick={async () => {
|
|
||||||
customUiFontStatus = null;
|
|
||||||
try {
|
|
||||||
await configStore.setCustomUiFont(
|
|
||||||
customUiFontPathInput || null,
|
|
||||||
customUiFontFamilyInput || null
|
|
||||||
);
|
|
||||||
customUiFontStatus = "Font applied!";
|
|
||||||
} catch (e) {
|
|
||||||
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
Apply UI Font
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={async () => {
|
|
||||||
customUiFontStatus = null;
|
|
||||||
customUiFontPathInput = "";
|
|
||||||
customUiFontFamilyInput = "";
|
|
||||||
try {
|
|
||||||
await configStore.setCustomUiFont(null, null);
|
|
||||||
await applyCustomUiFont(null, null);
|
|
||||||
customUiFontStatus = "Font reset to default.";
|
|
||||||
} catch (e) {
|
|
||||||
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if customUiFontStatus}
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">{customUiFontStatus}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
|
||||||
Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs,
|
|
||||||
direct font file URLs, or local file paths.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Show Thinking Blocks Toggle -->
|
<!-- Show Thinking Blocks Toggle -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
|||||||
@@ -1011,7 +1011,7 @@ User: ${formattedMessage}`;
|
|||||||
: "Connect to Claude first..."}
|
: "Connect to Claude first..."}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
rows={1}
|
rows={1}
|
||||||
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
|
||||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
||||||
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
||||||
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
|||||||
@@ -3,22 +3,17 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
|
|
||||||
interface MemoryFileInfo {
|
let memoryFiles: string[] = $state([]);
|
||||||
path: string;
|
|
||||||
heading: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MemoryFilesResponse {
|
|
||||||
files: MemoryFileInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let memoryFiles: MemoryFileInfo[] = $state([]);
|
|
||||||
let selectedFile: string | null = $state(null);
|
let selectedFile: string | null = $state(null);
|
||||||
let fileContent: string = $state("");
|
let fileContent: string = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let isPanelOpen = $state(false);
|
let isPanelOpen = $state(false);
|
||||||
|
|
||||||
|
interface MemoryFilesResponse {
|
||||||
|
files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMemoryFiles() {
|
async function loadMemoryFiles() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = null;
|
error = null;
|
||||||
@@ -54,10 +49,6 @@
|
|||||||
return path.split("/").pop() || path;
|
return path.split("/").pop() || path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDisplayName(file: MemoryFileInfo): string {
|
|
||||||
return file.heading ?? getFileName(file.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePanel() {
|
function togglePanel() {
|
||||||
isPanelOpen = !isPanelOpen;
|
isPanelOpen = !isPanelOpen;
|
||||||
if (isPanelOpen && memoryFiles.length === 0) {
|
if (isPanelOpen && memoryFiles.length === 0) {
|
||||||
@@ -160,12 +151,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="panel-layout">
|
<div class="panel-layout">
|
||||||
<div class="file-list">
|
<div class="file-list">
|
||||||
{#each memoryFiles as file (file.path)}
|
{#each memoryFiles as file (file)}
|
||||||
<button
|
<button
|
||||||
class="file-item"
|
class="file-item"
|
||||||
class:active={selectedFile === file.path}
|
class:active={selectedFile === file}
|
||||||
onclick={() => loadFileContent(file.path)}
|
onclick={() => loadFileContent(file)}
|
||||||
title={getFileName(file.path)}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="file-icon"
|
class="file-icon"
|
||||||
@@ -181,7 +171,7 @@
|
|||||||
d="M9 12h6m-6 4h6m2 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"
|
d="M9 12h6m-6 4h6m2 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>
|
</svg>
|
||||||
<span class="file-name">{getDisplayName(file)}</span>
|
<span class="file-name">{getFileName(file)}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -189,12 +179,7 @@
|
|||||||
<div class="file-viewer">
|
<div class="file-viewer">
|
||||||
{#if selectedFile && fileContent}
|
{#if selectedFile && fileContent}
|
||||||
<div class="viewer-header">
|
<div class="viewer-header">
|
||||||
{#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
|
<h4>{getFileName(selectedFile)}</h4>
|
||||||
<h4>{getDisplayName(activeFile)}</h4>
|
|
||||||
{#if activeFile.heading}
|
|
||||||
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer-content">
|
<div class="viewer-content">
|
||||||
<Markdown content={fileContent} />
|
<Markdown content={fileContent} />
|
||||||
@@ -453,13 +438,6 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-filename {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewer-content {
|
.viewer-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
// Mirror pure logic functions from MemoryBrowserPanel.svelte
|
|
||||||
|
|
||||||
interface MemoryFileInfo {
|
|
||||||
path: string;
|
|
||||||
heading: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileName(path: string): string {
|
|
||||||
return path.split("/").pop() || path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayName(file: MemoryFileInfo): string {
|
|
||||||
return file.heading ?? getFileName(file.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("getFileName", () => {
|
|
||||||
it("extracts the filename from an absolute Unix path", () => {
|
|
||||||
expect(getFileName("/home/naomi/.claude/projects/foo/memory/MEMORY.md")).toBe("MEMORY.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts the filename from a nested path", () => {
|
|
||||||
expect(getFileName("/home/naomi/.claude/projects/foo/memory/debugging.md")).toBe(
|
|
||||||
"debugging.md"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the path itself when there is no slash", () => {
|
|
||||||
expect(getFileName("MEMORY.md")).toBe("MEMORY.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the path when the path ends with a slash (empty filename)", () => {
|
|
||||||
// split('/').pop() returns '' for trailing slash → falls back to full path
|
|
||||||
expect(getFileName("/some/dir/")).toBe("/some/dir/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles single-component paths", () => {
|
|
||||||
expect(getFileName("notes.md")).toBe("notes.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles Windows-style paths passed as Unix strings", () => {
|
|
||||||
// If the path contains no forward slashes, the whole string is the filename
|
|
||||||
expect(getFileName("C:\\Users\\naomi\\memory\\file.md")).toBe(
|
|
||||||
"C:\\Users\\naomi\\memory\\file.md"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getDisplayName", () => {
|
|
||||||
it("returns the heading when the file has one", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
|
|
||||||
heading: "Hikari Desktop - Memory",
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("Hikari Desktop - Memory");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the filename when heading is null", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "/home/naomi/.claude/projects/foo/memory/debugging.md",
|
|
||||||
heading: null,
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("debugging.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the filename when heading is an empty string stored as null", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "/home/naomi/.claude/projects/foo/memory/patterns.md",
|
|
||||||
heading: null,
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("patterns.md");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the heading even when it matches the filename", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
|
|
||||||
heading: "MEMORY",
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("MEMORY");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a multi-word heading verbatim", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "/some/path/foo.md",
|
|
||||||
heading: "My Detailed Debugging Notes",
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("My Detailed Debugging Notes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back gracefully when path has no directory separators", () => {
|
|
||||||
const file: MemoryFileInfo = {
|
|
||||||
path: "lonely-file.md",
|
|
||||||
heading: null,
|
|
||||||
};
|
|
||||||
expect(getDisplayName(file)).toBe("lonely-file.md");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -111,10 +111,6 @@
|
|||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
|
||||||
custom_font_family: null,
|
|
||||||
custom_ui_font_path: null,
|
|
||||||
custom_ui_font_family: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
|
|||||||
@@ -231,7 +231,7 @@
|
|||||||
bind:this={terminalElement}
|
bind:this={terminalElement}
|
||||||
onscroll={handleScroll}
|
onscroll={handleScroll}
|
||||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
||||||
style="font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
style="font-size: var(--terminal-font-size, 14px);"
|
||||||
>
|
>
|
||||||
{#if lines.length === 0}
|
{#if lines.length === 0}
|
||||||
<div class="terminal-waiting italic">
|
<div class="terminal-waiting italic">
|
||||||
|
|||||||
+114
-431
@@ -1,212 +1,259 @@
|
|||||||
/**
|
/**
|
||||||
* Clipboard Store Tests
|
* Clipboard Store Tests
|
||||||
*
|
*
|
||||||
* Tests the pure helper functions and store actions from the clipboard store:
|
* Tests the pure helper functions from the clipboard store:
|
||||||
* - detectLanguage: identifies programming language from code content
|
* - detectLanguage: identifies programming language from code content
|
||||||
* - formatTimestamp: converts an ISO timestamp to a relative time string
|
* - formatTimestamp: converts an ISO timestamp to a relative time string
|
||||||
* - Store actions: loadEntries, captureClipboard, deleteEntry, togglePin, etc.
|
*
|
||||||
* - Derived stores: filteredEntries (language + search filtering), languages
|
* What this store does:
|
||||||
|
* - Maintains a history of clipboard entries (code snippets)
|
||||||
|
* - Auto-detects the language of captured content
|
||||||
|
* - Supports filtering by language and search query
|
||||||
|
* - Pinned entries persist across clear operations
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Clipboard entries appear when code is copied during a session
|
||||||
|
* - [ ] Language is detected and labelled correctly
|
||||||
|
* - [ ] Pinned entries survive "Clear history"
|
||||||
|
* - [ ] Language filter dropdown shows only languages present in history
|
||||||
|
* - [ ] Search query filters by content, language, and source
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
// Mirror: detectLanguage from clipboard.ts
|
||||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
function detectLanguage(content: string): string | null {
|
||||||
|
const patterns: [RegExp, string][] = [
|
||||||
|
[/^(import|export|const|let|var|function|class|interface|type)\s/m, "typescript"],
|
||||||
|
[/^(def|class|import|from|if __name__|async def)\s/m, "python"],
|
||||||
|
[/^(fn|let|mut|impl|struct|enum|use|mod|pub)\s/m, "rust"],
|
||||||
|
[/^(package|import|func|type|var|const)\s/m, "go"],
|
||||||
|
[/<\?php/m, "php"],
|
||||||
|
[/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/im, "sql"],
|
||||||
|
[/^<!DOCTYPE|^<html|^<div|^<span/im, "html"],
|
||||||
|
[/^\s*\{[\s\S]*"[\w-]+":/m, "json"],
|
||||||
|
[/^---\s*\n/m, "yaml"],
|
||||||
|
[/^\s*#\s*(include|define|ifdef)/m, "c"],
|
||||||
|
[/^(public|private|protected)\s+(class|interface|static)/m, "java"],
|
||||||
|
[/^\$[\w_]+\s*=/m, "bash"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [pattern, lang] of patterns) {
|
||||||
|
if (pattern.test(content)) {
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror: formatTimestamp from clipboard.ts
|
||||||
|
function formatTimestamp(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return "Just now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
describe("detectLanguage", () => {
|
describe("detectLanguage", () => {
|
||||||
describe("TypeScript detection", () => {
|
describe("TypeScript detection", () => {
|
||||||
it("detects import statements", () => {
|
it("detects import statements", () => {
|
||||||
expect(clipboardStore.detectLanguage("import React from 'react';")).toBe("typescript");
|
expect(detectLanguage("import React from 'react';")).toBe("typescript");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects export statements", () => {
|
it("detects export statements", () => {
|
||||||
expect(clipboardStore.detectLanguage("export function foo() {}")).toBe("typescript");
|
expect(detectLanguage("export function foo() {}")).toBe("typescript");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects const declarations", () => {
|
it("detects const declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage("const x = 1;")).toBe("typescript");
|
expect(detectLanguage("const x = 1;")).toBe("typescript");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects interface declarations", () => {
|
it("detects interface declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage("interface Foo {\n bar: string;\n}")).toBe(
|
expect(detectLanguage("interface Foo {\n bar: string;\n}")).toBe("typescript");
|
||||||
"typescript"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects type aliases", () => {
|
it("detects type aliases", () => {
|
||||||
expect(clipboardStore.detectLanguage("type MyType = string | number;")).toBe("typescript");
|
expect(detectLanguage("type MyType = string | number;")).toBe("typescript");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Python detection", () => {
|
describe("Python detection", () => {
|
||||||
it("detects def statements", () => {
|
it("detects def statements", () => {
|
||||||
expect(clipboardStore.detectLanguage("def foo():\n pass")).toBe("python");
|
expect(detectLanguage("def foo():\n pass")).toBe("python");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects async def statements", () => {
|
it("detects async def statements", () => {
|
||||||
expect(clipboardStore.detectLanguage("async def bar():\n pass")).toBe("python");
|
expect(detectLanguage("async def bar():\n pass")).toBe("python");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects from imports", () => {
|
it("detects from imports", () => {
|
||||||
expect(clipboardStore.detectLanguage("from os import path")).toBe("python");
|
expect(detectLanguage("from os import path")).toBe("python");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects the __name__ guard", () => {
|
it("detects the __name__ guard", () => {
|
||||||
expect(clipboardStore.detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
|
expect(detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Rust detection", () => {
|
describe("Rust detection", () => {
|
||||||
it("detects fn declarations", () => {
|
it("detects fn declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
|
expect(detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects impl blocks", () => {
|
it("detects impl blocks", () => {
|
||||||
expect(clipboardStore.detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe(
|
expect(detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe("rust");
|
||||||
"rust"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects struct declarations", () => {
|
it("detects struct declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
|
expect(detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects enum declarations", () => {
|
it("detects enum declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
|
expect(detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects mod declarations", () => {
|
it("detects mod declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage("mod utils;")).toBe("rust");
|
expect(detectLanguage("mod utils;")).toBe("rust");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects pub visibility", () => {
|
it("detects pub visibility", () => {
|
||||||
expect(clipboardStore.detectLanguage("pub fn exported() {}")).toBe("rust");
|
expect(detectLanguage("pub fn exported() {}")).toBe("rust");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Go detection", () => {
|
describe("Go detection", () => {
|
||||||
it("detects package declarations", () => {
|
it("detects package declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage("package main")).toBe("go");
|
expect(detectLanguage("package main")).toBe("go");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects func declarations", () => {
|
it("detects func declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage("func main() {}")).toBe("go");
|
expect(detectLanguage("func main() {}")).toBe("go");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PHP detection", () => {
|
describe("PHP detection", () => {
|
||||||
it("detects the PHP open tag", () => {
|
it("detects the PHP open tag", () => {
|
||||||
expect(clipboardStore.detectLanguage("<?php\necho 'hello';")).toBe("php");
|
expect(detectLanguage("<?php\necho 'hello';")).toBe("php");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SQL detection", () => {
|
describe("SQL detection", () => {
|
||||||
it("detects SELECT statements", () => {
|
it("detects SELECT statements", () => {
|
||||||
expect(clipboardStore.detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
|
expect(detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects INSERT statements", () => {
|
it("detects INSERT statements", () => {
|
||||||
expect(clipboardStore.detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe(
|
expect(detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe("sql");
|
||||||
"sql"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects CREATE statements", () => {
|
it("detects CREATE statements", () => {
|
||||||
expect(clipboardStore.detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
|
expect(detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects SQL case-insensitively", () => {
|
it("detects SQL case-insensitively", () => {
|
||||||
expect(clipboardStore.detectLanguage("select * from users")).toBe("sql");
|
expect(detectLanguage("select * from users")).toBe("sql");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("HTML detection", () => {
|
describe("HTML detection", () => {
|
||||||
it("detects DOCTYPE declarations", () => {
|
it("detects DOCTYPE declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage("<!DOCTYPE html>")).toBe("html");
|
expect(detectLanguage("<!DOCTYPE html>")).toBe("html");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects html tags", () => {
|
it("detects html tags", () => {
|
||||||
expect(clipboardStore.detectLanguage("<html><body></body></html>")).toBe("html");
|
expect(detectLanguage("<html><body></body></html>")).toBe("html");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects div tags", () => {
|
it("detects div tags", () => {
|
||||||
expect(clipboardStore.detectLanguage("<div class='foo'>bar</div>")).toBe("html");
|
expect(detectLanguage("<div class='foo'>bar</div>")).toBe("html");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects span tags", () => {
|
it("detects span tags", () => {
|
||||||
expect(clipboardStore.detectLanguage("<span>text</span>")).toBe("html");
|
expect(detectLanguage("<span>text</span>")).toBe("html");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("JSON detection", () => {
|
describe("JSON detection", () => {
|
||||||
it("detects JSON object syntax", () => {
|
it("detects JSON object syntax", () => {
|
||||||
expect(clipboardStore.detectLanguage('{"name": "test", "value": 42}')).toBe("json");
|
expect(detectLanguage('{"name": "test", "value": 42}')).toBe("json");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects JSON with hyphenated keys", () => {
|
it("detects JSON with hyphenated keys", () => {
|
||||||
expect(clipboardStore.detectLanguage('{"my-key": "value"}')).toBe("json");
|
expect(detectLanguage('{"my-key": "value"}')).toBe("json");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("YAML detection", () => {
|
describe("YAML detection", () => {
|
||||||
it("detects YAML document separator", () => {
|
it("detects YAML document separator", () => {
|
||||||
expect(clipboardStore.detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
|
expect(detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("C detection", () => {
|
describe("C detection", () => {
|
||||||
it("detects #include directives", () => {
|
it("detects #include directives", () => {
|
||||||
expect(clipboardStore.detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
|
expect(detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects #define directives", () => {
|
it("detects #define directives", () => {
|
||||||
expect(clipboardStore.detectLanguage("#define MAX 100")).toBe("c");
|
expect(detectLanguage("#define MAX 100")).toBe("c");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects #ifdef directives", () => {
|
it("detects #ifdef directives", () => {
|
||||||
expect(clipboardStore.detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
|
expect(detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Java detection", () => {
|
describe("Java detection", () => {
|
||||||
it("detects public class declarations", () => {
|
it("detects public class declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
|
expect(detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects private static methods", () => {
|
it("detects private static methods", () => {
|
||||||
expect(clipboardStore.detectLanguage("private static void helper() {}")).toBe("java");
|
expect(detectLanguage("private static void helper() {}")).toBe("java");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects protected interface declarations", () => {
|
it("detects protected interface declarations", () => {
|
||||||
expect(clipboardStore.detectLanguage("protected interface Bar {}")).toBe("java");
|
expect(detectLanguage("protected interface Bar {}")).toBe("java");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Bash detection", () => {
|
describe("Bash detection", () => {
|
||||||
it("detects shell variable assignments", () => {
|
it("detects shell variable assignments", () => {
|
||||||
expect(clipboardStore.detectLanguage("$HOME=/usr/local")).toBe("bash");
|
expect(detectLanguage("$HOME=/usr/local")).toBe("bash");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects variable assignments with underscores", () => {
|
it("detects variable assignments with underscores", () => {
|
||||||
expect(clipboardStore.detectLanguage("$MY_VAR=some_value")).toBe("bash");
|
expect(detectLanguage("$MY_VAR=some_value")).toBe("bash");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("unknown content", () => {
|
describe("unknown content", () => {
|
||||||
it("returns null for plain text", () => {
|
it("returns null for plain text", () => {
|
||||||
expect(clipboardStore.detectLanguage("Hello, world!")).toBeNull();
|
expect(detectLanguage("Hello, world!")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for empty string", () => {
|
it("returns null for empty string", () => {
|
||||||
expect(clipboardStore.detectLanguage("")).toBeNull();
|
expect(detectLanguage("")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for mathematical expressions", () => {
|
it("returns null for mathematical expressions", () => {
|
||||||
expect(clipboardStore.detectLanguage("1 + 1 = 2")).toBeNull();
|
expect(detectLanguage("1 + 1 = 2")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for a markdown heading", () => {
|
it("returns null for a markdown heading", () => {
|
||||||
expect(clipboardStore.detectLanguage("# My Heading\nSome text")).toBeNull();
|
expect(detectLanguage("# My Heading\nSome text")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -226,75 +273,75 @@ describe("formatTimestamp", () => {
|
|||||||
describe("'Just now' threshold (< 1 minute)", () => {
|
describe("'Just now' threshold (< 1 minute)", () => {
|
||||||
it("returns 'Just now' for a timestamp 30 seconds ago", () => {
|
it("returns 'Just now' for a timestamp 30 seconds ago", () => {
|
||||||
const ts = new Date("2026-03-03T11:59:30.000Z").toISOString();
|
const ts = new Date("2026-03-03T11:59:30.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
expect(formatTimestamp(ts)).toBe("Just now");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 'Just now' for the current moment", () => {
|
it("returns 'Just now' for the current moment", () => {
|
||||||
const ts = NOW.toISOString();
|
const ts = NOW.toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
expect(formatTimestamp(ts)).toBe("Just now");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 'Just now' for a timestamp 59 seconds ago", () => {
|
it("returns 'Just now' for a timestamp 59 seconds ago", () => {
|
||||||
const ts = new Date("2026-03-03T11:59:01.000Z").toISOString();
|
const ts = new Date("2026-03-03T11:59:01.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
expect(formatTimestamp(ts)).toBe("Just now");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("'Xm ago' threshold (1–59 minutes)", () => {
|
describe("'Xm ago' threshold (1–59 minutes)", () => {
|
||||||
it("returns '1m ago' at exactly 1 minute", () => {
|
it("returns '1m ago' at exactly 1 minute", () => {
|
||||||
const ts = new Date("2026-03-03T11:59:00.000Z").toISOString();
|
const ts = new Date("2026-03-03T11:59:00.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("1m ago");
|
expect(formatTimestamp(ts)).toBe("1m ago");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns '5m ago' for a timestamp 5 minutes ago", () => {
|
it("returns '5m ago' for a timestamp 5 minutes ago", () => {
|
||||||
const ts = new Date("2026-03-03T11:55:00.000Z").toISOString();
|
const ts = new Date("2026-03-03T11:55:00.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("5m ago");
|
expect(formatTimestamp(ts)).toBe("5m ago");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns '59m ago' just before the 1-hour threshold", () => {
|
it("returns '59m ago' just before the 1-hour threshold", () => {
|
||||||
const ts = new Date("2026-03-03T11:01:00.000Z").toISOString();
|
const ts = new Date("2026-03-03T11:01:00.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("59m ago");
|
expect(formatTimestamp(ts)).toBe("59m ago");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("'Xh ago' threshold (1–23 hours)", () => {
|
describe("'Xh ago' threshold (1–23 hours)", () => {
|
||||||
it("returns '1h ago' at exactly 1 hour", () => {
|
it("returns '1h ago' at exactly 1 hour", () => {
|
||||||
const ts = new Date("2026-03-03T11:00:00.000Z").toISOString();
|
const ts = new Date("2026-03-03T11:00:00.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("1h ago");
|
expect(formatTimestamp(ts)).toBe("1h ago");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns '2h ago' for a timestamp 2 hours ago", () => {
|
it("returns '2h ago' for a timestamp 2 hours ago", () => {
|
||||||
const ts = new Date("2026-03-03T10:00:00.000Z").toISOString();
|
const ts = new Date("2026-03-03T10:00:00.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("2h ago");
|
expect(formatTimestamp(ts)).toBe("2h ago");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns '23h ago' just before the 1-day threshold", () => {
|
it("returns '23h ago' just before the 1-day threshold", () => {
|
||||||
const ts = new Date("2026-03-02T13:00:00.000Z").toISOString();
|
const ts = new Date("2026-03-02T13:00:00.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("23h ago");
|
expect(formatTimestamp(ts)).toBe("23h ago");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("'Xd ago' threshold (1–6 days)", () => {
|
describe("'Xd ago' threshold (1–6 days)", () => {
|
||||||
it("returns '1d ago' at exactly 1 day", () => {
|
it("returns '1d ago' at exactly 1 day", () => {
|
||||||
const ts = new Date("2026-03-02T12:00:00.000Z").toISOString();
|
const ts = new Date("2026-03-02T12:00:00.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("1d ago");
|
expect(formatTimestamp(ts)).toBe("1d ago");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns '3d ago' for a timestamp 3 days ago", () => {
|
it("returns '3d ago' for a timestamp 3 days ago", () => {
|
||||||
const ts = new Date("2026-02-28T12:00:00.000Z").toISOString();
|
const ts = new Date("2026-02-28T12:00:00.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("3d ago");
|
expect(formatTimestamp(ts)).toBe("3d ago");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns '6d ago' just before the 7-day threshold", () => {
|
it("returns '6d ago' just before the 7-day threshold", () => {
|
||||||
const ts = new Date("2026-02-25T12:00:00.000Z").toISOString();
|
const ts = new Date("2026-02-25T12:00:00.000Z").toISOString();
|
||||||
expect(clipboardStore.formatTimestamp(ts)).toBe("6d ago");
|
expect(formatTimestamp(ts)).toBe("6d ago");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("locale date string (7+ days ago)", () => {
|
describe("locale date string (7+ days ago)", () => {
|
||||||
it("returns a locale date string for a 2-week-old timestamp", () => {
|
it("returns a locale date string for a 2-week-old timestamp", () => {
|
||||||
const ts = new Date("2026-02-17T12:00:00.000Z").toISOString();
|
const ts = new Date("2026-02-17T12:00:00.000Z").toISOString();
|
||||||
const result = clipboardStore.formatTimestamp(ts);
|
const result = formatTimestamp(ts);
|
||||||
// Should not be a relative time string
|
// Should not be a relative time string
|
||||||
expect(result).not.toContain("m ago");
|
expect(result).not.toContain("m ago");
|
||||||
expect(result).not.toContain("h ago");
|
expect(result).not.toContain("h ago");
|
||||||
@@ -304,372 +351,8 @@ describe("formatTimestamp", () => {
|
|||||||
|
|
||||||
it("returns a locale date string for a 1-month-old timestamp", () => {
|
it("returns a locale date string for a 1-month-old timestamp", () => {
|
||||||
const ts = new Date("2026-02-03T12:00:00.000Z").toISOString();
|
const ts = new Date("2026-02-03T12:00:00.000Z").toISOString();
|
||||||
const result = clipboardStore.formatTimestamp(ts);
|
const result = formatTimestamp(ts);
|
||||||
expect(result).not.toContain("ago");
|
expect(result).not.toContain("ago");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clipboardStore - derived stores", () => {
|
|
||||||
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
|
|
||||||
id: "entry-1",
|
|
||||||
content: "const x = 1;",
|
|
||||||
language: "typescript",
|
|
||||||
source: "test.ts",
|
|
||||||
timestamp: "2026-03-03T12:00:00.000Z",
|
|
||||||
is_pinned: false,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clipboardStore.entries.set([]);
|
|
||||||
clipboardStore.searchQuery.set("");
|
|
||||||
clipboardStore.languageFilter.set(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("filteredEntries - language filter", () => {
|
|
||||||
it("returns all entries when no language filter is set", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ id: "1", language: "typescript" }),
|
|
||||||
makeEntry({ id: "2", language: "python" }),
|
|
||||||
]);
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters entries by language", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ id: "1", language: "typescript" }),
|
|
||||||
makeEntry({ id: "2", language: "python" }),
|
|
||||||
makeEntry({ id: "3", language: "typescript" }),
|
|
||||||
]);
|
|
||||||
clipboardStore.languageFilter.set("typescript");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(2);
|
|
||||||
expect(filtered.every((e) => e.language === "typescript")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array when filter matches nothing", () => {
|
|
||||||
clipboardStore.entries.set([makeEntry({ language: "typescript" })]);
|
|
||||||
clipboardStore.languageFilter.set("rust");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("filteredEntries - search query", () => {
|
|
||||||
it("filters by content (case-insensitive)", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ id: "1", content: "const HELLO = 1;" }),
|
|
||||||
makeEntry({ id: "2", content: "let world = 2;" }),
|
|
||||||
]);
|
|
||||||
clipboardStore.searchQuery.set("hello");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(1);
|
|
||||||
expect(filtered[0].id).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by language field", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ id: "1", language: "typescript", content: "unrelated" }),
|
|
||||||
makeEntry({ id: "2", language: "python", content: "also unrelated" }),
|
|
||||||
]);
|
|
||||||
clipboardStore.searchQuery.set("python");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(1);
|
|
||||||
expect(filtered[0].id).toBe("2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by source field", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ id: "1", source: "main.rs", content: "fn main() {}" }),
|
|
||||||
makeEntry({ id: "2", source: "app.ts", content: "const x = 1;" }),
|
|
||||||
]);
|
|
||||||
clipboardStore.searchQuery.set("main.rs");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(1);
|
|
||||||
expect(filtered[0].id).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all entries when search query is empty", () => {
|
|
||||||
clipboardStore.entries.set([makeEntry({ id: "1" }), makeEntry({ id: "2" })]);
|
|
||||||
clipboardStore.searchQuery.set("");
|
|
||||||
const filtered = get(clipboardStore.filteredEntries);
|
|
||||||
expect(filtered).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("languages derived store", () => {
|
|
||||||
it("returns empty array when no entries", () => {
|
|
||||||
clipboardStore.entries.set([]);
|
|
||||||
expect(get(clipboardStore.languages)).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns unique sorted languages", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ language: "typescript" }),
|
|
||||||
makeEntry({ language: "python" }),
|
|
||||||
makeEntry({ language: "typescript" }),
|
|
||||||
makeEntry({ language: "rust" }),
|
|
||||||
]);
|
|
||||||
const langs = get(clipboardStore.languages);
|
|
||||||
expect(langs).toEqual(["python", "rust", "typescript"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes entries with null language", () => {
|
|
||||||
clipboardStore.entries.set([
|
|
||||||
makeEntry({ language: "typescript" }),
|
|
||||||
makeEntry({ language: null }),
|
|
||||||
]);
|
|
||||||
const langs = get(clipboardStore.languages);
|
|
||||||
expect(langs).toEqual(["typescript"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clipboardStore - setSearchQuery and setLanguageFilter", () => {
|
|
||||||
it("setSearchQuery updates the searchQuery store", () => {
|
|
||||||
clipboardStore.setSearchQuery("hello world");
|
|
||||||
expect(get(clipboardStore.searchQuery)).toBe("hello world");
|
|
||||||
clipboardStore.setSearchQuery("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setLanguageFilter updates the languageFilter store", () => {
|
|
||||||
clipboardStore.setLanguageFilter("python");
|
|
||||||
expect(get(clipboardStore.languageFilter)).toBe("python");
|
|
||||||
clipboardStore.setLanguageFilter(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setLanguageFilter can be reset to null", () => {
|
|
||||||
clipboardStore.setLanguageFilter("rust");
|
|
||||||
clipboardStore.setLanguageFilter(null);
|
|
||||||
expect(get(clipboardStore.languageFilter)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clipboardStore - store actions", () => {
|
|
||||||
const mockEntry = {
|
|
||||||
id: "entry-1",
|
|
||||||
content: "const x = 1;",
|
|
||||||
language: "typescript",
|
|
||||||
source: "test.ts",
|
|
||||||
timestamp: "2026-03-03T12:00:00.000Z",
|
|
||||||
is_pinned: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
clipboardStore.entries.set([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("loadEntries", () => {
|
|
||||||
it("loads entries from backend and updates the store", async () => {
|
|
||||||
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
|
||||||
await clipboardStore.loadEntries();
|
|
||||||
expect(get(clipboardStore.entries)).toEqual([mockEntry]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully without crashing", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("list_clipboard_entries", new Error("Backend unavailable"));
|
|
||||||
await clipboardStore.loadEntries();
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to load clipboard entries:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets isLoading to false after completion", async () => {
|
|
||||||
setMockInvokeResult("list_clipboard_entries", []);
|
|
||||||
await clipboardStore.loadEntries();
|
|
||||||
expect(get(clipboardStore.isLoading)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("captureClipboard", () => {
|
|
||||||
it("captures clipboard entry with provided language", async () => {
|
|
||||||
setMockInvokeResult("capture_clipboard", mockEntry);
|
|
||||||
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
|
||||||
const result = await clipboardStore.captureClipboard("const x = 1;", "typescript", "test.ts");
|
|
||||||
expect(result).toEqual(mockEntry);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-detects language when none provided", async () => {
|
|
||||||
setMockInvokeResult("capture_clipboard", mockEntry);
|
|
||||||
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
|
||||||
const result = await clipboardStore.captureClipboard("const x = 1;");
|
|
||||||
expect(result).toEqual(mockEntry);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null on error", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("capture_clipboard", new Error("Failed"));
|
|
||||||
const result = await clipboardStore.captureClipboard("const x = 1;");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deleteEntry", () => {
|
|
||||||
it("removes entry from store on success", async () => {
|
|
||||||
clipboardStore.entries.set([mockEntry, { ...mockEntry, id: "entry-2" }]);
|
|
||||||
setMockInvokeResult("delete_clipboard_entry", undefined);
|
|
||||||
await clipboardStore.deleteEntry("entry-1");
|
|
||||||
expect(get(clipboardStore.entries)).toHaveLength(1);
|
|
||||||
expect(get(clipboardStore.entries)[0].id).toBe("entry-2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("delete_clipboard_entry", new Error("Failed"));
|
|
||||||
await clipboardStore.deleteEntry("entry-1");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to delete clipboard entry:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("togglePin", () => {
|
|
||||||
it("updates entry pin status and sorts pinned first", async () => {
|
|
||||||
const unpinned1 = { ...mockEntry, id: "entry-1", is_pinned: false };
|
|
||||||
const unpinned2 = { ...mockEntry, id: "entry-2", is_pinned: false };
|
|
||||||
clipboardStore.entries.set([unpinned1, unpinned2]);
|
|
||||||
const pinned = { ...unpinned2, is_pinned: true };
|
|
||||||
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
|
|
||||||
await clipboardStore.togglePin("entry-2");
|
|
||||||
const entries = get(clipboardStore.entries);
|
|
||||||
expect(entries[0].id).toBe("entry-2");
|
|
||||||
expect(entries[0].is_pinned).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("toggle_pin_clipboard_entry", new Error("Failed"));
|
|
||||||
await clipboardStore.togglePin("entry-1");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to toggle pin:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exercises the unpinned-before-pinned sort branch", async () => {
|
|
||||||
// Three entries so sort compares (entry-1 unpinned, entry-2 pinned) — hits line 144
|
|
||||||
const e1 = { ...mockEntry, id: "e1", is_pinned: false };
|
|
||||||
const e2 = { ...mockEntry, id: "e2", is_pinned: false };
|
|
||||||
const e3 = { ...mockEntry, id: "e3", is_pinned: false };
|
|
||||||
clipboardStore.entries.set([e1, e2, e3]);
|
|
||||||
const pinned = { ...e2, is_pinned: true };
|
|
||||||
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
|
|
||||||
await clipboardStore.togglePin("e2");
|
|
||||||
expect(get(clipboardStore.entries)[0].id).toBe("e2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sorts same-pin-status entries by timestamp descending", async () => {
|
|
||||||
// Toggle entry to pinned while two others remain unpinned — sort compares unpinned pair by timestamp
|
|
||||||
const older = {
|
|
||||||
...mockEntry,
|
|
||||||
id: "older",
|
|
||||||
is_pinned: false,
|
|
||||||
timestamp: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
const newer = {
|
|
||||||
...mockEntry,
|
|
||||||
id: "newer",
|
|
||||||
is_pinned: false,
|
|
||||||
timestamp: "2026-01-02T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
const pinned = {
|
|
||||||
...mockEntry,
|
|
||||||
id: "pinned",
|
|
||||||
is_pinned: false,
|
|
||||||
timestamp: "2026-01-03T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
clipboardStore.entries.set([older, newer, pinned]);
|
|
||||||
const pinnedResult = { ...pinned, is_pinned: true };
|
|
||||||
setMockInvokeResult("toggle_pin_clipboard_entry", pinnedResult);
|
|
||||||
await clipboardStore.togglePin("pinned");
|
|
||||||
const entries = get(clipboardStore.entries);
|
|
||||||
expect(entries[0].id).toBe("pinned");
|
|
||||||
expect(entries[1].id).toBe("newer");
|
|
||||||
expect(entries[2].id).toBe("older");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clearHistory", () => {
|
|
||||||
it("removes unpinned entries and keeps pinned ones", async () => {
|
|
||||||
const pinned = { ...mockEntry, id: "pinned", is_pinned: true };
|
|
||||||
const unpinned = { ...mockEntry, id: "unpinned", is_pinned: false };
|
|
||||||
clipboardStore.entries.set([pinned, unpinned]);
|
|
||||||
setMockInvokeResult("clear_clipboard_history", undefined);
|
|
||||||
await clipboardStore.clearHistory();
|
|
||||||
const entries = get(clipboardStore.entries);
|
|
||||||
expect(entries).toHaveLength(1);
|
|
||||||
expect(entries[0].id).toBe("pinned");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("clear_clipboard_history", new Error("Failed"));
|
|
||||||
await clipboardStore.clearHistory();
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to clear clipboard history:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("updateLanguage", () => {
|
|
||||||
it("updates entry language in the store", async () => {
|
|
||||||
clipboardStore.entries.set([mockEntry]);
|
|
||||||
const updated = { ...mockEntry, language: "javascript" };
|
|
||||||
setMockInvokeResult("update_clipboard_language", updated);
|
|
||||||
await clipboardStore.updateLanguage("entry-1", "javascript");
|
|
||||||
expect(get(clipboardStore.entries)[0].language).toBe("javascript");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves non-matching entries unchanged", async () => {
|
|
||||||
const other = { ...mockEntry, id: "other", language: "python" };
|
|
||||||
clipboardStore.entries.set([mockEntry, other]);
|
|
||||||
const updated = { ...mockEntry, language: "javascript" };
|
|
||||||
setMockInvokeResult("update_clipboard_language", updated);
|
|
||||||
await clipboardStore.updateLanguage("entry-1", "javascript");
|
|
||||||
const entries = get(clipboardStore.entries);
|
|
||||||
expect(entries[1].language).toBe("python");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("update_clipboard_language", new Error("Failed"));
|
|
||||||
await clipboardStore.updateLanguage("entry-1", "javascript");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to update language:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("copyToClipboard", () => {
|
|
||||||
it("returns true and copies text on success", async () => {
|
|
||||||
Object.assign(navigator, {
|
|
||||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
||||||
});
|
|
||||||
const result = await clipboardStore.copyToClipboard("hello world");
|
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false and logs error on failure", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
Object.assign(navigator, {
|
|
||||||
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard denied")) },
|
|
||||||
});
|
|
||||||
const result = await clipboardStore.copyToClipboard("hello world");
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to copy to clipboard:",
|
|
||||||
expect.any(Error)
|
|
||||||
);
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
maskPaths,
|
maskPaths,
|
||||||
clampFontSize,
|
clampFontSize,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
applyCustomFont,
|
|
||||||
applyCustomUiFont,
|
|
||||||
applyTheme,
|
applyTheme,
|
||||||
applyCustomThemeColors,
|
applyCustomThemeColors,
|
||||||
clearCustomThemeColors,
|
clearCustomThemeColors,
|
||||||
@@ -23,17 +21,12 @@ import {
|
|||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
} from "./config";
|
} from "./config";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
|
||||||
|
|
||||||
// Mock Tauri APIs
|
// Mock Tauri APIs
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
|
||||||
readFile: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("config store", () => {
|
describe("config store", () => {
|
||||||
describe("font size constants", () => {
|
describe("font size constants", () => {
|
||||||
it("has correct MIN_FONT_SIZE", () => {
|
it("has correct MIN_FONT_SIZE", () => {
|
||||||
@@ -213,10 +206,6 @@ describe("config store", () => {
|
|||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
|
||||||
custom_font_family: null,
|
|
||||||
custom_ui_font_path: null,
|
|
||||||
custom_ui_font_family: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -269,10 +258,6 @@ describe("config store", () => {
|
|||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
|
||||||
custom_font_family: null,
|
|
||||||
custom_ui_font_path: null,
|
|
||||||
custom_ui_font_family: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
@@ -824,10 +809,6 @@ describe("config store", () => {
|
|||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
|
||||||
custom_font_family: null,
|
|
||||||
custom_ui_font_path: null,
|
|
||||||
custom_ui_font_family: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockInvokeImpl = vi.mocked(invoke);
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
@@ -1152,323 +1133,4 @@ describe("config store", () => {
|
|||||||
expect(get(showThinkingBlocks)).toBe(false);
|
expect(get(showThinkingBlocks)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("applyCustomFont", () => {
|
|
||||||
const readFileMock = vi.mocked(readFile);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Remove any style element left by previous tests
|
|
||||||
document.getElementById("hikari-custom-font")?.remove();
|
|
||||||
document.documentElement.style.removeProperty("--terminal-font-family");
|
|
||||||
readFileMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes CSS variable when both path and family are null", async () => {
|
|
||||||
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
|
||||||
await applyCustomFont(null, null);
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes CSS variable when both path and family are empty strings", async () => {
|
|
||||||
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
|
||||||
await applyCustomFont("", "");
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
|
||||||
expect(document.getElementById("hikari-custom-font")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects @import for a CSS stylesheet URL", async () => {
|
|
||||||
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
|
|
||||||
const style = document.getElementById("hikari-custom-font");
|
|
||||||
expect(style).not.toBeNull();
|
|
||||||
expect(style?.textContent).toContain(
|
|
||||||
"@import url('https://fonts.googleapis.com/css2?family=Fira+Code')"
|
|
||||||
);
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
|
||||||
"'Fira Code', monospace"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects @font-face for a direct font file URL (.woff2)", async () => {
|
|
||||||
await applyCustomFont("https://example.com/fonts/myfont.woff2", "MyFont");
|
|
||||||
const style = document.getElementById("hikari-custom-font");
|
|
||||||
expect(style).not.toBeNull();
|
|
||||||
expect(style?.textContent).toContain("@font-face");
|
|
||||||
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.woff2')");
|
|
||||||
expect(style?.textContent).toContain("'MyFont'");
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
|
||||||
"'MyFont', monospace"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects @font-face for a direct font file URL (.ttf)", async () => {
|
|
||||||
await applyCustomFont("https://example.com/fonts/myfont.ttf", "MyTtfFont");
|
|
||||||
const style = document.getElementById("hikari-custom-font");
|
|
||||||
expect(style).not.toBeNull();
|
|
||||||
expect(style?.textContent).toContain("@font-face");
|
|
||||||
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.ttf')");
|
|
||||||
expect(style?.textContent).toContain("'MyTtfFont'");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses HikariCustomFont as fallback family for direct font URLs when family is empty", async () => {
|
|
||||||
await applyCustomFont("https://example.com/fonts/myfont.woff2", "");
|
|
||||||
const style = document.getElementById("hikari-custom-font");
|
|
||||||
expect(style?.textContent).toContain("'HikariCustomFont'");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads local file and injects @font-face with data URL", async () => {
|
|
||||||
const fakeData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
|
||||||
readFileMock.mockResolvedValueOnce(fakeData);
|
|
||||||
|
|
||||||
await applyCustomFont("/home/naomi/.fonts/MyFont.ttf", "MyFont");
|
|
||||||
|
|
||||||
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/MyFont.ttf");
|
|
||||||
const style = document.getElementById("hikari-custom-font");
|
|
||||||
expect(style).not.toBeNull();
|
|
||||||
expect(style?.textContent).toContain("@font-face");
|
|
||||||
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
|
||||||
expect(style?.textContent).toContain("'MyFont'");
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
|
||||||
"'MyFont', monospace"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses HikariCustomFont as fallback family for local files when family is empty", async () => {
|
|
||||||
const fakeData = new Uint8Array([1, 2, 3]);
|
|
||||||
readFileMock.mockResolvedValueOnce(fakeData);
|
|
||||||
|
|
||||||
await applyCustomFont("/home/naomi/.fonts/MyFont.woff2", "");
|
|
||||||
|
|
||||||
const style = document.getElementById("hikari-custom-font");
|
|
||||||
expect(style?.textContent).toContain("'HikariCustomFont'");
|
|
||||||
expect(style?.textContent).toContain("font/woff2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets CSS variable when only family is provided (no path)", async () => {
|
|
||||||
await applyCustomFont("", "SystemFont");
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
|
||||||
"'SystemFont', monospace"
|
|
||||||
);
|
|
||||||
expect(document.getElementById("hikari-custom-font")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces a previously injected style element", async () => {
|
|
||||||
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
|
|
||||||
expect(document.getElementById("hikari-custom-font")).not.toBeNull();
|
|
||||||
|
|
||||||
await applyCustomFont("https://fonts.googleapis.com/css2?family=Roboto+Mono", "Roboto Mono");
|
|
||||||
const styles = document.querySelectorAll("#hikari-custom-font");
|
|
||||||
expect(styles.length).toBe(1);
|
|
||||||
expect(styles[0].textContent).toContain("Roboto+Mono");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses correct MIME type for .otf local files", async () => {
|
|
||||||
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
|
|
||||||
await applyCustomFont("/fonts/MyFont.otf", "OtfFont");
|
|
||||||
const style = document.getElementById("hikari-custom-font");
|
|
||||||
expect(style?.textContent).toContain("font/otf");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to font/ttf MIME for unknown extension local files", async () => {
|
|
||||||
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
|
|
||||||
await applyCustomFont("/fonts/MyFont.xyz", "XyzFont");
|
|
||||||
const style = document.getElementById("hikari-custom-font");
|
|
||||||
expect(style?.textContent).toContain("font/ttf");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setCustomFont", () => {
|
|
||||||
const readFileMock = vi.mocked(readFile);
|
|
||||||
const invokeMock = vi.mocked(invoke);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
document.getElementById("hikari-custom-font")?.remove();
|
|
||||||
document.documentElement.style.removeProperty("--terminal-font-family");
|
|
||||||
readFileMock.mockReset();
|
|
||||||
invokeMock.mockResolvedValue(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves config and applies the font", async () => {
|
|
||||||
await configStore.setCustomFont(null, null);
|
|
||||||
await configStore.setCustomFont(
|
|
||||||
"https://fonts.googleapis.com/css2?family=Fira+Code",
|
|
||||||
"Fira Code"
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith(
|
|
||||||
"save_config",
|
|
||||||
expect.objectContaining({
|
|
||||||
config: expect.objectContaining({
|
|
||||||
custom_font_path: "https://fonts.googleapis.com/css2?family=Fira+Code",
|
|
||||||
custom_font_family: "Fira Code",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
|
||||||
"'Fira Code', monospace"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears font when called with nulls", async () => {
|
|
||||||
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
|
||||||
await configStore.setCustomFont(null, null);
|
|
||||||
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith(
|
|
||||||
"save_config",
|
|
||||||
expect.objectContaining({
|
|
||||||
config: expect.objectContaining({
|
|
||||||
custom_font_path: null,
|
|
||||||
custom_font_family: null,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("applyCustomUiFont", () => {
|
|
||||||
const readFileMock = vi.mocked(readFile);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
document.getElementById("hikari-custom-ui-font")?.remove();
|
|
||||||
document.documentElement.style.removeProperty("--ui-font-family");
|
|
||||||
document.body.style.removeProperty("font-family");
|
|
||||||
readFileMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes CSS variable and body font-family when both path and family are null", async () => {
|
|
||||||
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
|
||||||
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
|
||||||
await applyCustomUiFont(null, null);
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
|
||||||
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes CSS variable and body font-family when both path and family are empty strings", async () => {
|
|
||||||
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
|
||||||
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
|
||||||
await applyCustomUiFont("", "");
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
|
||||||
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
|
||||||
expect(document.getElementById("hikari-custom-ui-font")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects @import for a CSS stylesheet URL and applies font to body", async () => {
|
|
||||||
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
|
||||||
const style = document.getElementById("hikari-custom-ui-font");
|
|
||||||
expect(style).not.toBeNull();
|
|
||||||
expect(style?.textContent).toContain(
|
|
||||||
"@import url('https://fonts.googleapis.com/css2?family=Inter')"
|
|
||||||
);
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
|
||||||
"'Inter', sans-serif"
|
|
||||||
);
|
|
||||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects @font-face for a direct font file URL (.woff2) and applies font to body", async () => {
|
|
||||||
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "Inter");
|
|
||||||
const style = document.getElementById("hikari-custom-ui-font");
|
|
||||||
expect(style).not.toBeNull();
|
|
||||||
expect(style?.textContent).toContain("@font-face");
|
|
||||||
expect(style?.textContent).toContain("url('https://example.com/fonts/Inter.woff2')");
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
|
||||||
"'Inter', sans-serif"
|
|
||||||
);
|
|
||||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses HikariCustomUiFont as fallback family and applies it to body", async () => {
|
|
||||||
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "");
|
|
||||||
const style = document.getElementById("hikari-custom-ui-font");
|
|
||||||
expect(style?.textContent).toContain("'HikariCustomUiFont'");
|
|
||||||
expect(document.body.style.getPropertyValue("font-family")).toBe(
|
|
||||||
'"HikariCustomUiFont", sans-serif'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads local file and embeds as data URL, applies font to body", async () => {
|
|
||||||
const fakeData = new Uint8Array([104, 101, 108, 108, 111]);
|
|
||||||
readFileMock.mockResolvedValueOnce(fakeData);
|
|
||||||
|
|
||||||
await applyCustomUiFont("/home/naomi/.fonts/Inter.ttf", "Inter");
|
|
||||||
|
|
||||||
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/Inter.ttf");
|
|
||||||
const style = document.getElementById("hikari-custom-ui-font");
|
|
||||||
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
|
||||||
"'Inter', sans-serif"
|
|
||||||
);
|
|
||||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets CSS variable and body font-family when only family is provided (no path)", async () => {
|
|
||||||
await applyCustomUiFont("", "SystemUiFont");
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
|
||||||
"'SystemUiFont', sans-serif"
|
|
||||||
);
|
|
||||||
expect(document.body.style.getPropertyValue("font-family")).toBe(
|
|
||||||
'"SystemUiFont", sans-serif'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces a previously injected style element", async () => {
|
|
||||||
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
|
||||||
expect(document.getElementById("hikari-custom-ui-font")).not.toBeNull();
|
|
||||||
|
|
||||||
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Roboto", "Roboto");
|
|
||||||
const styles = document.querySelectorAll("#hikari-custom-ui-font");
|
|
||||||
expect(styles.length).toBe(1);
|
|
||||||
expect(styles[0].textContent).toContain("Roboto");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setCustomUiFont", () => {
|
|
||||||
const readFileMock = vi.mocked(readFile);
|
|
||||||
const invokeMock = vi.mocked(invoke);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
document.getElementById("hikari-custom-ui-font")?.remove();
|
|
||||||
document.documentElement.style.removeProperty("--ui-font-family");
|
|
||||||
document.body.style.removeProperty("font-family");
|
|
||||||
readFileMock.mockReset();
|
|
||||||
invokeMock.mockResolvedValue(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves config and applies the UI font", async () => {
|
|
||||||
await configStore.setCustomUiFont(null, null);
|
|
||||||
await configStore.setCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
|
||||||
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith(
|
|
||||||
"save_config",
|
|
||||||
expect.objectContaining({
|
|
||||||
config: expect.objectContaining({
|
|
||||||
custom_ui_font_path: "https://fonts.googleapis.com/css2?family=Inter",
|
|
||||||
custom_ui_font_family: "Inter",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
|
||||||
"'Inter', sans-serif"
|
|
||||||
);
|
|
||||||
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears UI font when called with nulls", async () => {
|
|
||||||
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
|
||||||
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
|
||||||
await configStore.setCustomUiFont(null, null);
|
|
||||||
|
|
||||||
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
|
||||||
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith(
|
|
||||||
"save_config",
|
|
||||||
expect.objectContaining({
|
|
||||||
config: expect.objectContaining({
|
|
||||||
custom_ui_font_path: null,
|
|
||||||
custom_ui_font_family: null,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
|
||||||
|
|
||||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||||
export type BudgetAction = "warn" | "block";
|
export type BudgetAction = "warn" | "block";
|
||||||
@@ -59,12 +58,6 @@ export interface HikariConfig {
|
|||||||
// Background image settings
|
// Background image settings
|
||||||
background_image_path: string | null;
|
background_image_path: string | null;
|
||||||
background_image_opacity: number;
|
background_image_opacity: number;
|
||||||
// Custom terminal font settings
|
|
||||||
custom_font_path: string | null;
|
|
||||||
custom_font_family: string | null;
|
|
||||||
// Custom UI font settings
|
|
||||||
custom_ui_font_path: string | null;
|
|
||||||
custom_ui_font_family: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -111,10 +104,6 @@ const defaultConfig: HikariConfig = {
|
|||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
|
||||||
custom_font_family: null,
|
|
||||||
custom_ui_font_path: null,
|
|
||||||
custom_ui_font_family: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
@@ -251,16 +240,6 @@ function createConfigStore() {
|
|||||||
setCompactMode: async (enabled: boolean) => {
|
setCompactMode: async (enabled: boolean) => {
|
||||||
await updateConfig({ compact_mode: enabled });
|
await updateConfig({ compact_mode: enabled });
|
||||||
},
|
},
|
||||||
|
|
||||||
setCustomFont: async (path: string | null, family: string | null) => {
|
|
||||||
await updateConfig({ custom_font_path: path, custom_font_family: family });
|
|
||||||
await applyCustomFont(path, family);
|
|
||||||
},
|
|
||||||
|
|
||||||
setCustomUiFont: async (path: string | null, family: string | null) => {
|
|
||||||
await updateConfig({ custom_ui_font_path: path, custom_ui_font_family: family });
|
|
||||||
await applyCustomUiFont(path, family);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,99 +305,6 @@ export function clampFontSize(size: number): number {
|
|||||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]);
|
|
||||||
|
|
||||||
const FONT_MIME_MAP: Record<string, string> = {
|
|
||||||
woff: "font/woff",
|
|
||||||
woff2: "font/woff2",
|
|
||||||
ttf: "font/ttf",
|
|
||||||
otf: "font/otf",
|
|
||||||
eot: "application/vnd.ms-fontobject",
|
|
||||||
};
|
|
||||||
|
|
||||||
async function applyFontFromSource(path: string, family: string, styleId: string): Promise<void> {
|
|
||||||
const style = document.createElement("style");
|
|
||||||
style.id = styleId;
|
|
||||||
|
|
||||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
||||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
||||||
|
|
||||||
if (DIRECT_FONT_EXTENSIONS.has(ext)) {
|
|
||||||
style.textContent = `@font-face { font-family: '${family}'; src: url('${path}'); }`;
|
|
||||||
} else {
|
|
||||||
style.textContent = `@import url('${path}');`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const data = await readFile(path);
|
|
||||||
const chunks: string[] = [];
|
|
||||||
const chunkSize = 8192;
|
|
||||||
for (let i = 0; i < data.length; i += chunkSize) {
|
|
||||||
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
|
|
||||||
}
|
|
||||||
const ext = path.split(".").pop()?.toLowerCase() ?? "ttf";
|
|
||||||
const mime = FONT_MIME_MAP[ext] ?? "font/ttf";
|
|
||||||
const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
|
|
||||||
style.textContent = `@font-face { font-family: '${family}'; src: url('${dataUrl}'); }`;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
|
|
||||||
const styleId = "hikari-custom-font";
|
|
||||||
const cssVar = "--terminal-font-family";
|
|
||||||
const fallbackFamily = "HikariCustomFont";
|
|
||||||
|
|
||||||
document.getElementById(styleId)?.remove();
|
|
||||||
|
|
||||||
const trimmedPath = path?.trim() ?? "";
|
|
||||||
const trimmedFamily = family?.trim() ?? "";
|
|
||||||
|
|
||||||
if (!trimmedPath && !trimmedFamily) {
|
|
||||||
document.documentElement.style.removeProperty(cssVar);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedPath) {
|
|
||||||
await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedFamily) {
|
|
||||||
document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', monospace`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyCustomUiFont(path: string | null, family: string | null): Promise<void> {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
|
|
||||||
const styleId = "hikari-custom-ui-font";
|
|
||||||
const cssVar = "--ui-font-family";
|
|
||||||
const fallbackFamily = "HikariCustomUiFont";
|
|
||||||
|
|
||||||
document.getElementById(styleId)?.remove();
|
|
||||||
|
|
||||||
const trimmedPath = path?.trim() ?? "";
|
|
||||||
const trimmedFamily = family?.trim() ?? "";
|
|
||||||
|
|
||||||
if (!trimmedPath && !trimmedFamily) {
|
|
||||||
document.documentElement.style.removeProperty(cssVar);
|
|
||||||
document.body?.style.removeProperty("font-family");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveFamily = trimmedFamily || fallbackFamily;
|
|
||||||
|
|
||||||
if (trimmedPath) {
|
|
||||||
await applyFontFromSource(trimmedPath, effectiveFamily, styleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontValue = `'${effectiveFamily}', sans-serif`;
|
|
||||||
document.documentElement.style.setProperty(cssVar, fontValue);
|
|
||||||
document.body?.style.setProperty("font-family", fontValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
||||||
|
|
||||||
export const configStore = createConfigStore();
|
export const configStore = createConfigStore();
|
||||||
|
|||||||
@@ -1,639 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { editorStore } from "$lib/stores/editor";
|
|
||||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
|
||||||
|
|
||||||
const FILE_CONTENT = "// test file content";
|
|
||||||
|
|
||||||
// Reset tabs between tests
|
|
||||||
afterEach(async () => {
|
|
||||||
const tabs = get(editorStore.tabs);
|
|
||||||
for (const tab of tabs) {
|
|
||||||
editorStore.closeTab(tab.id);
|
|
||||||
}
|
|
||||||
editorStore.hideEditor();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - openFile (tests getLanguageFromPath)", () => {
|
|
||||||
const testCases: [string, string][] = [
|
|
||||||
["file.ts", "typescript"],
|
|
||||||
["file.tsx", "typescript"],
|
|
||||||
["file.js", "javascript"],
|
|
||||||
["file.jsx", "javascript"],
|
|
||||||
["file.py", "python"],
|
|
||||||
["file.rs", "rust"],
|
|
||||||
["file.go", "go"],
|
|
||||||
["file.java", "java"],
|
|
||||||
["file.c", "c"],
|
|
||||||
["file.cpp", "cpp"],
|
|
||||||
["file.h", "c"],
|
|
||||||
["file.hpp", "cpp"],
|
|
||||||
["file.cs", "csharp"],
|
|
||||||
["file.rb", "ruby"],
|
|
||||||
["file.php", "php"],
|
|
||||||
["file.swift", "swift"],
|
|
||||||
["file.kt", "kotlin"],
|
|
||||||
["file.scala", "scala"],
|
|
||||||
["file.html", "html"],
|
|
||||||
["file.htm", "html"],
|
|
||||||
["file.css", "css"],
|
|
||||||
["file.scss", "scss"],
|
|
||||||
["file.sass", "sass"],
|
|
||||||
["file.less", "less"],
|
|
||||||
["file.json", "json"],
|
|
||||||
["file.xml", "xml"],
|
|
||||||
["file.yaml", "yaml"],
|
|
||||||
["file.yml", "yaml"],
|
|
||||||
["file.toml", "toml"],
|
|
||||||
["file.md", "markdown"],
|
|
||||||
["file.markdown", "markdown"],
|
|
||||||
["file.sql", "sql"],
|
|
||||||
["file.sh", "shell"],
|
|
||||||
["file.bash", "shell"],
|
|
||||||
["file.zsh", "shell"],
|
|
||||||
["file.ps1", "powershell"],
|
|
||||||
["file.svelte", "svelte"],
|
|
||||||
["file.vue", "vue"],
|
|
||||||
["file.graphql", "graphql"],
|
|
||||||
["file.gql", "graphql"],
|
|
||||||
["file.lua", "lua"],
|
|
||||||
["file.r", "r"],
|
|
||||||
["file.dart", "dart"],
|
|
||||||
["file.elm", "elm"],
|
|
||||||
["file.ex", "elixir"],
|
|
||||||
["file.exs", "elixir"],
|
|
||||||
["file.erl", "erlang"],
|
|
||||||
["file.hs", "haskell"],
|
|
||||||
["file.clj", "clojure"],
|
|
||||||
["file.lisp", "lisp"],
|
|
||||||
["file.ml", "ocaml"],
|
|
||||||
["file.fs", "fsharp"],
|
|
||||||
["file.zig", "zig"],
|
|
||||||
["file.nim", "nim"],
|
|
||||||
["file.v", "v"],
|
|
||||||
["file.wasm", "wasm"],
|
|
||||||
["file.wat", "wasm"],
|
|
||||||
["dockerfile", "plaintext"],
|
|
||||||
["file.unknown", "plaintext"],
|
|
||||||
["file", "plaintext"],
|
|
||||||
];
|
|
||||||
|
|
||||||
it.each(testCases)("maps %s to language %s", async (filename, expectedLanguage) => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile(`/path/to/${filename}`);
|
|
||||||
const activeTab = get(editorStore.activeTab);
|
|
||||||
expect(activeTab?.language).toBe(expectedLanguage);
|
|
||||||
if (activeTab) editorStore.closeTab(activeTab.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens a new tab and makes it active", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
const tabs = get(editorStore.tabs);
|
|
||||||
const activeTab = get(editorStore.activeTab);
|
|
||||||
expect(tabs).toHaveLength(1);
|
|
||||||
expect(activeTab?.fileName).toBe("file.ts");
|
|
||||||
expect(activeTab?.content).toBe(FILE_CONTENT);
|
|
||||||
expect(activeTab?.isDirty).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to existing tab instead of opening a duplicate", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
const firstTabId = get(editorStore.activeTabId);
|
|
||||||
|
|
||||||
// Open another file to change active tab
|
|
||||||
await editorStore.openFile("/path/to/other.ts");
|
|
||||||
const secondTabId = get(editorStore.activeTabId);
|
|
||||||
expect(secondTabId).not.toBe(firstTabId);
|
|
||||||
|
|
||||||
// Re-open first file — should switch to existing tab
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
expect(get(editorStore.activeTabId)).toBe(firstTabId);
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles open file errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("read_file_content", new Error("File not found"));
|
|
||||||
await editorStore.openFile("/path/to/missing.ts");
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(0);
|
|
||||||
expect(get(editorStore.saveError)).toContain("Failed to open file");
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts filename from path correctly", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/deep/nested/path/component.svelte");
|
|
||||||
expect(get(editorStore.activeTab)?.fileName).toBe("component.svelte");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - saveFile", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
// Make it dirty first
|
|
||||||
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves the active tab successfully", async () => {
|
|
||||||
setMockInvokeResult("write_file_content", undefined);
|
|
||||||
await editorStore.saveFile();
|
|
||||||
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
|
||||||
expect(get(editorStore.activeTab)?.originalContent).toBe("modified content");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves a specific tab by ID", async () => {
|
|
||||||
setMockInvokeResult("write_file_content", undefined);
|
|
||||||
const tabId = get(editorStore.activeTabId)!;
|
|
||||||
await editorStore.saveFile(tabId);
|
|
||||||
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when no tab ID matches", async () => {
|
|
||||||
await editorStore.saveFile("non-existent-tab");
|
|
||||||
// No error = pass
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - saveFile error", () => {
|
|
||||||
it("sets saveError when write fails", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file-err.ts");
|
|
||||||
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("write_file_content", new Error("Permission denied"));
|
|
||||||
await editorStore.saveFile().catch(() => {});
|
|
||||||
expect(get(editorStore.saveError)).toContain("Failed to save file");
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - updateTabContent", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates content and marks tab as dirty", () => {
|
|
||||||
const tabId = get(editorStore.activeTabId)!;
|
|
||||||
editorStore.updateTabContent(tabId, "new content");
|
|
||||||
const tab = get(editorStore.activeTab);
|
|
||||||
expect(tab?.content).toBe("new content");
|
|
||||||
expect(tab?.isDirty).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks tab as clean when content matches original", () => {
|
|
||||||
const tabId = get(editorStore.activeTabId)!;
|
|
||||||
editorStore.updateTabContent(tabId, "modified");
|
|
||||||
expect(get(editorStore.activeTab)?.isDirty).toBe(true);
|
|
||||||
editorStore.updateTabContent(tabId, FILE_CONTENT);
|
|
||||||
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasDirtyTabs is true when any tab is dirty", () => {
|
|
||||||
const tabId = get(editorStore.activeTabId)!;
|
|
||||||
expect(get(editorStore.hasDirtyTabs)).toBe(false);
|
|
||||||
editorStore.updateTabContent(tabId, "dirty content");
|
|
||||||
expect(get(editorStore.hasDirtyTabs)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - closeTab", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file1.ts");
|
|
||||||
await editorStore.openFile("/path/to/file2.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes the specified tab", () => {
|
|
||||||
const tabs = get(editorStore.tabs);
|
|
||||||
editorStore.closeTab(tabs[0].id);
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(1);
|
|
||||||
expect(get(editorStore.tabs)[0].filePath).toBe("/path/to/file2.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets active tab to remaining tab when active tab is closed", () => {
|
|
||||||
const activeTabId = get(editorStore.activeTabId)!;
|
|
||||||
editorStore.closeTab(activeTabId);
|
|
||||||
expect(get(editorStore.activeTabId)).not.toBe(activeTabId);
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - setActiveTab", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file1.ts");
|
|
||||||
await editorStore.openFile("/path/to/file2.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets the active tab to the specified ID", () => {
|
|
||||||
const tabs = get(editorStore.tabs);
|
|
||||||
editorStore.setActiveTab(tabs[0].id);
|
|
||||||
expect(get(editorStore.activeTabId)).toBe(tabs[0].id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - visibility controls", () => {
|
|
||||||
it("showEditor sets isEditorVisible to true", () => {
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
|
||||||
editorStore.showEditor();
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hideEditor sets isEditorVisible to false", () => {
|
|
||||||
editorStore.showEditor();
|
|
||||||
editorStore.hideEditor();
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toggleEditor toggles isEditorVisible", () => {
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
|
||||||
editorStore.toggleEditor();
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(true);
|
|
||||||
editorStore.toggleEditor();
|
|
||||||
expect(get(editorStore.isEditorVisible)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - toggleFileBrowser", () => {
|
|
||||||
it("toggles the file browser visibility", () => {
|
|
||||||
const initial = get(editorStore.isFileBrowserOpen);
|
|
||||||
editorStore.toggleFileBrowser();
|
|
||||||
expect(get(editorStore.isFileBrowserOpen)).toBe(!initial);
|
|
||||||
editorStore.toggleFileBrowser();
|
|
||||||
expect(get(editorStore.isFileBrowserOpen)).toBe(initial);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - file system operations", () => {
|
|
||||||
it("createFile invokes backend and refreshes directory", async () => {
|
|
||||||
setMockInvokeResult("create_file", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.createFile("/path/to", "new-file.ts");
|
|
||||||
// No error = success
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createFile handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("create_file", new Error("Permission denied"));
|
|
||||||
await editorStore.createFile("/path/to", "new-file.ts");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create file:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createDirectory invokes backend and refreshes", async () => {
|
|
||||||
setMockInvokeResult("create_directory", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.createDirectory("/path/to", "new-dir");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createDirectory handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("create_directory", new Error("Failed"));
|
|
||||||
await editorStore.createDirectory("/path/to", "new-dir");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create directory:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteFile invokes backend and refreshes", async () => {
|
|
||||||
setMockInvokeResult("delete_file", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.deleteFile("/path/to/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteFile handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("delete_file", new Error("Failed"));
|
|
||||||
await editorStore.deleteFile("/path/to/file.ts");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete file:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteDirectory invokes backend and refreshes", async () => {
|
|
||||||
setMockInvokeResult("delete_directory", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.deleteDirectory("/path/to/dir");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteDirectory handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("delete_directory", new Error("Failed"));
|
|
||||||
await editorStore.deleteDirectory("/path/to/dir");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete directory:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renamePath invokes backend and refreshes", async () => {
|
|
||||||
setMockInvokeResult("rename_path", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renamePath handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("rename_path", new Error("Failed"));
|
|
||||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to rename:", expect.any(Error));
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - initializeFileTree", () => {
|
|
||||||
it("loads directory entries and updates file tree", async () => {
|
|
||||||
const entries = [
|
|
||||||
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", entries);
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
expect(get(editorStore.fileTree)).toEqual(entries);
|
|
||||||
expect(get(editorStore.currentDirectory)).toBe("/path");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("list_directory", new Error("Failed"));
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
expect(get(editorStore.isLoadingTree)).toBe(false);
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - toggleDirectory", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
const entries = [
|
|
||||||
{
|
|
||||||
path: "/path/dir",
|
|
||||||
name: "dir",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", entries);
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when entry is not a directory", async () => {
|
|
||||||
const nonDirEntry = {
|
|
||||||
path: "/path/file.ts",
|
|
||||||
name: "file.ts",
|
|
||||||
isDirectory: false,
|
|
||||||
};
|
|
||||||
const treeBefore = get(editorStore.fileTree);
|
|
||||||
await editorStore.toggleDirectory(nonDirEntry);
|
|
||||||
expect(get(editorStore.fileTree)).toEqual(treeBefore);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expands a collapsed directory and loads children asynchronously", async () => {
|
|
||||||
const children = [
|
|
||||||
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", children);
|
|
||||||
|
|
||||||
const dirEntry = get(editorStore.fileTree)[0];
|
|
||||||
await editorStore.toggleDirectory(dirEntry);
|
|
||||||
|
|
||||||
const updatedEntry = get(editorStore.fileTree)[0];
|
|
||||||
expect(updatedEntry.isExpanded).toBe(true);
|
|
||||||
expect(updatedEntry.children).toEqual(children);
|
|
||||||
expect(updatedEntry.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapses an expanded directory", async () => {
|
|
||||||
const children = [
|
|
||||||
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", children);
|
|
||||||
|
|
||||||
// Expand first
|
|
||||||
const dirEntry = get(editorStore.fileTree)[0];
|
|
||||||
await editorStore.toggleDirectory(dirEntry);
|
|
||||||
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
|
|
||||||
|
|
||||||
// Now collapse
|
|
||||||
const expandedEntry = get(editorStore.fileTree)[0];
|
|
||||||
await editorStore.toggleDirectory(expandedEntry);
|
|
||||||
expect(get(editorStore.fileTree)[0].isExpanded).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expands a directory that already has children without reloading", async () => {
|
|
||||||
// Re-init with a directory that already has children
|
|
||||||
const entries = [
|
|
||||||
{
|
|
||||||
path: "/path/dir",
|
|
||||||
name: "dir",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: false,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "/path/dir/child.ts",
|
|
||||||
name: "child.ts",
|
|
||||||
isDirectory: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", entries);
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
|
|
||||||
// Toggle should expand without loading (children already present)
|
|
||||||
const dirEntry = get(editorStore.fileTree)[0];
|
|
||||||
await editorStore.toggleDirectory(dirEntry);
|
|
||||||
|
|
||||||
const updatedEntry = get(editorStore.fileTree)[0];
|
|
||||||
expect(updatedEntry.isExpanded).toBe(true);
|
|
||||||
expect(updatedEntry.children).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles tree with multiple entries when toggling — exercises non-matching leaf fallback", async () => {
|
|
||||||
// Tree has dir + a sibling file; toggling dir exercises the `return e` branch for the file
|
|
||||||
const entries = [
|
|
||||||
{
|
|
||||||
path: "/path/dir",
|
|
||||||
name: "dir",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", entries);
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
|
|
||||||
const children: never[] = [];
|
|
||||||
setMockInvokeResult("list_directory", children);
|
|
||||||
const dirEntry = get(editorStore.fileTree)[0];
|
|
||||||
await editorStore.toggleDirectory(dirEntry);
|
|
||||||
|
|
||||||
// The sibling file should remain unchanged
|
|
||||||
expect(get(editorStore.fileTree)[1].path).toBe("/path/file.ts");
|
|
||||||
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles nested directory toggle — exercises recursive updateTree branches", async () => {
|
|
||||||
// Tree: outer_dir → [inner_dir (no children), sibling_file]
|
|
||||||
// Toggling inner_dir covers the recursive path through outer's children
|
|
||||||
const entries = [
|
|
||||||
{
|
|
||||||
path: "/path/outer",
|
|
||||||
name: "outer",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: true,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "/path/outer/inner",
|
|
||||||
name: "inner",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/path/outer/sibling.ts",
|
|
||||||
name: "sibling.ts",
|
|
||||||
isDirectory: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", entries);
|
|
||||||
await editorStore.initializeFileTree("/path");
|
|
||||||
|
|
||||||
const innerChildren = [
|
|
||||||
{
|
|
||||||
path: "/path/outer/inner/deep.ts",
|
|
||||||
name: "deep.ts",
|
|
||||||
isDirectory: false,
|
|
||||||
children: undefined,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", innerChildren);
|
|
||||||
|
|
||||||
const innerEntry = get(editorStore.fileTree)[0].children![0];
|
|
||||||
await editorStore.toggleDirectory(innerEntry);
|
|
||||||
|
|
||||||
const outer = get(editorStore.fileTree)[0];
|
|
||||||
expect(outer.children![0].isExpanded).toBe(true);
|
|
||||||
expect(outer.children![0].children).toEqual(innerChildren);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - deleteFile closes open tab", () => {
|
|
||||||
it("closes the tab when the deleted file is open", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/file.ts");
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(1);
|
|
||||||
|
|
||||||
setMockInvokeResult("delete_file", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.deleteFile("/path/to/file.ts");
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - deleteDirectory closes open tabs", () => {
|
|
||||||
it("closes all tabs inside the deleted directory", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/dir/file1.ts");
|
|
||||||
await editorStore.openFile("/path/to/dir/nested/file2.ts");
|
|
||||||
await editorStore.openFile("/path/to/other.ts");
|
|
||||||
expect(get(editorStore.tabs)).toHaveLength(3);
|
|
||||||
|
|
||||||
setMockInvokeResult("delete_directory", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.deleteDirectory("/path/to/dir");
|
|
||||||
|
|
||||||
const remainingTabs = get(editorStore.tabs);
|
|
||||||
expect(remainingTabs).toHaveLength(1);
|
|
||||||
expect(remainingTabs[0].filePath).toBe("/path/to/other.ts");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - renamePath updates open tabs", () => {
|
|
||||||
it("updates filePath and fileName when the renamed file is open in a tab", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/old-name.ts");
|
|
||||||
|
|
||||||
setMockInvokeResult("rename_path", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
|
||||||
|
|
||||||
const tab = get(editorStore.activeTab);
|
|
||||||
expect(tab?.filePath).toBe("/path/to/new-name.ts");
|
|
||||||
expect(tab?.fileName).toBe("new-name.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates filePaths for tabs inside a renamed directory", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/old-dir/file.ts");
|
|
||||||
|
|
||||||
setMockInvokeResult("rename_path", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.renamePath("/path/to/old-dir", "new-dir");
|
|
||||||
|
|
||||||
const tab = get(editorStore.activeTab);
|
|
||||||
expect(tab?.filePath).toBe("/path/to/new-dir/file.ts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves unrelated tabs unchanged when renaming — exercises return-t fallback", async () => {
|
|
||||||
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
|
||||||
await editorStore.openFile("/path/to/unrelated.ts");
|
|
||||||
await editorStore.openFile("/path/to/old-name.ts");
|
|
||||||
|
|
||||||
setMockInvokeResult("rename_path", undefined);
|
|
||||||
setMockInvokeResult("list_directory", []);
|
|
||||||
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
|
||||||
|
|
||||||
const tabs = get(editorStore.tabs);
|
|
||||||
const unrelated = tabs.find((t) => t.filePath === "/path/to/unrelated.ts");
|
|
||||||
expect(unrelated).toBeDefined();
|
|
||||||
expect(unrelated?.fileName).toBe("unrelated.ts");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("editorStore - refreshDirectory", () => {
|
|
||||||
it("reloads the entire tree when refreshing the root directory", async () => {
|
|
||||||
const initialEntries = [
|
|
||||||
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", initialEntries);
|
|
||||||
await editorStore.initializeFileTree("/root");
|
|
||||||
expect(get(editorStore.fileTree)).toHaveLength(1);
|
|
||||||
|
|
||||||
const updatedEntries = [
|
|
||||||
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
|
||||||
{ path: "/root/new.ts", name: "new.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", updatedEntries);
|
|
||||||
await editorStore.refreshDirectory("/root");
|
|
||||||
expect(get(editorStore.fileTree)).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates a subdirectory entry in the tree", async () => {
|
|
||||||
const initialEntries = [
|
|
||||||
{
|
|
||||||
path: "/root/subdir",
|
|
||||||
name: "subdir",
|
|
||||||
isDirectory: true,
|
|
||||||
isExpanded: true,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", initialEntries);
|
|
||||||
await editorStore.initializeFileTree("/root");
|
|
||||||
|
|
||||||
const subChildren = [
|
|
||||||
{ path: "/root/subdir/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
|
||||||
];
|
|
||||||
setMockInvokeResult("list_directory", subChildren);
|
|
||||||
await editorStore.refreshDirectory("/root/subdir");
|
|
||||||
|
|
||||||
const tree = get(editorStore.fileTree);
|
|
||||||
expect(tree[0].children).toEqual(subChildren);
|
|
||||||
expect(tree[0].isExpanded).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,569 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { sessionsStore } from "$lib/stores/sessions";
|
|
||||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
|
||||||
import type { SavedSession } from "$lib/stores/sessions";
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
|
||||||
save: vi.fn(),
|
|
||||||
open: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
|
||||||
writeTextFile: vi.fn().mockResolvedValue(undefined),
|
|
||||||
readTextFile: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
|
||||||
openPath: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const makeSavedSession = (overrides: Partial<SavedSession> = {}): SavedSession => ({
|
|
||||||
id: "session-1",
|
|
||||||
name: "Test Session",
|
|
||||||
created_at: "2026-03-03T10:00:00.000Z",
|
|
||||||
last_activity_at: "2026-03-03T11:00:00.000Z",
|
|
||||||
working_directory: "/home/naomi/code",
|
|
||||||
message_count: 2,
|
|
||||||
preview: "Hello world",
|
|
||||||
messages: [
|
|
||||||
{ id: "msg-1", type: "user", content: "Hello world", timestamp: "2026-03-03T10:00:00.000Z" },
|
|
||||||
{ id: "msg-2", type: "assistant", content: "Hi there!", timestamp: "2026-03-03T10:01:00.000Z" },
|
|
||||||
],
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeConversation = () => ({
|
|
||||||
id: "conv-1",
|
|
||||||
name: "Test Conversation",
|
|
||||||
workingDirectory: "/home/naomi/code",
|
|
||||||
createdAt: new Date("2026-03-03T10:00:00.000Z"),
|
|
||||||
lastActivityAt: new Date("2026-03-03T11:00:00.000Z"),
|
|
||||||
terminalLines: [
|
|
||||||
{
|
|
||||||
id: "line-1",
|
|
||||||
type: "user",
|
|
||||||
content: "Hello",
|
|
||||||
timestamp: new Date("2026-03-03T10:00:00.000Z"),
|
|
||||||
toolName: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "line-2",
|
|
||||||
type: "assistant",
|
|
||||||
content: "Hi",
|
|
||||||
timestamp: new Date("2026-03-03T10:01:00.000Z"),
|
|
||||||
toolName: undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - loadSessions", () => {
|
|
||||||
it("loads sessions from backend and updates the store", async () => {
|
|
||||||
const sessionList = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
|
|
||||||
setMockInvokeResult("list_sessions", sessionList);
|
|
||||||
await sessionsStore.loadSessions();
|
|
||||||
expect(get(sessionsStore.sessions)).toEqual(sessionList);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("list_sessions", new Error("Backend error"));
|
|
||||||
await sessionsStore.loadSessions();
|
|
||||||
expect(spy).toHaveBeenCalledWith("Failed to load sessions:", expect.any(Error));
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets isLoading to false after completion", async () => {
|
|
||||||
setMockInvokeResult("list_sessions", []);
|
|
||||||
await sessionsStore.loadSessions();
|
|
||||||
expect(get(sessionsStore.isLoading)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - loadSession", () => {
|
|
||||||
it("returns session data on success", async () => {
|
|
||||||
const session = makeSavedSession();
|
|
||||||
setMockInvokeResult("load_session", session);
|
|
||||||
const result = await sessionsStore.loadSession("session-1");
|
|
||||||
expect(result).toEqual(session);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null on error", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", new Error("Not found"));
|
|
||||||
const result = await sessionsStore.loadSession("session-1");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - deleteSession", () => {
|
|
||||||
it("deletes session and reloads the session list", async () => {
|
|
||||||
setMockInvokeResult("delete_session", undefined);
|
|
||||||
setMockInvokeResult("list_sessions", []);
|
|
||||||
await sessionsStore.deleteSession("session-1");
|
|
||||||
expect(get(sessionsStore.sessions)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("delete_session", new Error("Failed"));
|
|
||||||
await sessionsStore.deleteSession("session-1");
|
|
||||||
expect(spy).toHaveBeenCalledWith("Failed to delete session:", expect.any(Error));
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - searchSessions", () => {
|
|
||||||
it("calls loadSessions when query is empty", async () => {
|
|
||||||
setMockInvokeResult("list_sessions", []);
|
|
||||||
await sessionsStore.searchSessions("");
|
|
||||||
expect(get(sessionsStore.sessions)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls loadSessions when query is whitespace-only", async () => {
|
|
||||||
setMockInvokeResult("list_sessions", []);
|
|
||||||
await sessionsStore.searchSessions(" ");
|
|
||||||
expect(get(sessionsStore.sessions)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("searches with the given query", async () => {
|
|
||||||
const results = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
|
|
||||||
setMockInvokeResult("search_sessions", results);
|
|
||||||
await sessionsStore.searchSessions("test");
|
|
||||||
expect(get(sessionsStore.sessions)).toEqual(results);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates searchQuery store", async () => {
|
|
||||||
setMockInvokeResult("search_sessions", []);
|
|
||||||
await sessionsStore.searchSessions("hello");
|
|
||||||
expect(get(sessionsStore.searchQuery)).toBe("hello");
|
|
||||||
await sessionsStore.searchSessions("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles search errors gracefully", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("search_sessions", new Error("Search failed"));
|
|
||||||
await sessionsStore.searchSessions("error-query");
|
|
||||||
expect(spy).toHaveBeenCalledWith("Failed to search sessions:", expect.any(Error));
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - clearAllSessions", () => {
|
|
||||||
it("clears all sessions", async () => {
|
|
||||||
setMockInvokeResult("clear_all_sessions", undefined);
|
|
||||||
await sessionsStore.clearAllSessions();
|
|
||||||
expect(get(sessionsStore.sessions)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("clear_all_sessions", new Error("Failed"));
|
|
||||||
await sessionsStore.clearAllSessions();
|
|
||||||
expect(spy).toHaveBeenCalledWith("Failed to clear sessions:", expect.any(Error));
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - saveConversation", () => {
|
|
||||||
it("saves conversation to backend", async () => {
|
|
||||||
setMockInvokeResult("save_session", undefined);
|
|
||||||
setMockInvokeResult("list_sessions", []);
|
|
||||||
await sessionsStore.saveConversation(makeConversation() as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles save errors gracefully", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("save_session", new Error("Save failed"));
|
|
||||||
await sessionsStore.saveConversation(makeConversation() as never);
|
|
||||||
expect(spy).toHaveBeenCalledWith("Failed to save session:", expect.any(Error));
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles empty conversation", async () => {
|
|
||||||
setMockInvokeResult("save_session", undefined);
|
|
||||||
setMockInvokeResult("list_sessions", []);
|
|
||||||
const conv = { ...makeConversation(), terminalLines: [] };
|
|
||||||
await sessionsStore.saveConversation(conv as never);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("schedules an auto-save after the debounce delay", async () => {
|
|
||||||
setMockInvokeResult("save_session", undefined);
|
|
||||||
setMockInvokeResult("list_sessions", []);
|
|
||||||
sessionsStore.scheduleAutoSave(makeConversation() as never);
|
|
||||||
await vi.advanceTimersByTimeAsync(2001);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not auto-save empty conversations", async () => {
|
|
||||||
const conv = { ...makeConversation(), terminalLines: [] };
|
|
||||||
sessionsStore.scheduleAutoSave(conv as never);
|
|
||||||
await vi.advanceTimersByTimeAsync(3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels a pending auto-save", () => {
|
|
||||||
const conv = makeConversation();
|
|
||||||
sessionsStore.scheduleAutoSave(conv as never);
|
|
||||||
sessionsStore.cancelAutoSave(conv.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles cancel when no auto-save is pending", () => {
|
|
||||||
sessionsStore.cancelAutoSave("non-existent-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears an existing pending auto-save when rescheduled for the same conversation", async () => {
|
|
||||||
setMockInvokeResult("save_session", undefined);
|
|
||||||
setMockInvokeResult("list_sessions", []);
|
|
||||||
const conv = makeConversation();
|
|
||||||
sessionsStore.scheduleAutoSave(conv as never);
|
|
||||||
// Schedule again before timer fires — hits clearTimeout branch (line 487)
|
|
||||||
sessionsStore.scheduleAutoSave(conv as never);
|
|
||||||
await vi.advanceTimersByTimeAsync(2001);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - exportSessionAsJson", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true on successful export", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session.json");
|
|
||||||
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when session not found", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", null);
|
|
||||||
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when user cancels save dialog", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue(null);
|
|
||||||
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false on error", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", new Error("Load failed"));
|
|
||||||
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when writeTextFile throws", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session.json");
|
|
||||||
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
|
||||||
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - exportSessionAsMarkdown", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates markdown with all message types", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
const session = makeSavedSession({
|
|
||||||
messages: [
|
|
||||||
{ id: "1", type: "user", content: "User message", timestamp: "2026-03-03T10:00:00Z" },
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
type: "assistant",
|
|
||||||
content: "Assistant reply",
|
|
||||||
timestamp: "2026-03-03T10:01:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
type: "tool_use",
|
|
||||||
content: "Tool input",
|
|
||||||
timestamp: "2026-03-03T10:02:00Z",
|
|
||||||
tool_name: "bash",
|
|
||||||
},
|
|
||||||
{ id: "4", type: "tool_result", content: "Tool output", timestamp: "2026-03-03T10:03:00Z" },
|
|
||||||
{ id: "5", type: "system", content: "System event", timestamp: "2026-03-03T10:04:00Z" },
|
|
||||||
{ id: "6", type: "error", content: "Error message", timestamp: "2026-03-03T10:05:00Z" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
setMockInvokeResult("load_session", session);
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session.md");
|
|
||||||
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(true);
|
|
||||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
|
||||||
expect(content).toContain("User message");
|
|
||||||
expect(content).toContain("Assistant reply");
|
|
||||||
expect(content).toContain("Tool: bash");
|
|
||||||
expect(content).toContain("Tool input");
|
|
||||||
expect(content).toContain("Tool output");
|
|
||||||
expect(content).toContain("System event");
|
|
||||||
expect(content).toContain("Error message");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when session not found", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", null);
|
|
||||||
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when user cancels dialog", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue(null);
|
|
||||||
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false on error", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", new Error("Load failed"));
|
|
||||||
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when writeTextFile throws", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session.md");
|
|
||||||
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
|
||||||
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - exportSessionAsHtml (tests escapeHtml + generateHtmlExport)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates HTML and returns true on success", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
|
||||||
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(true);
|
|
||||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
|
||||||
expect(content).toContain("<!DOCTYPE html>");
|
|
||||||
expect(content).toContain("Test Session");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escapes HTML characters in session name", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession({ name: "<Test & 'Session'>" }));
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
|
||||||
await sessionsStore.exportSessionAsHtml("session-1");
|
|
||||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
|
||||||
expect(content).toContain("<Test & 'Session'>");
|
|
||||||
expect(content).not.toContain("<Test & 'Session'>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escapes HTML characters in message content", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
setMockInvokeResult(
|
|
||||||
"load_session",
|
|
||||||
makeSavedSession({
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
type: "user",
|
|
||||||
content: '<script>alert("xss")</script>',
|
|
||||||
timestamp: "2026-03-03T10:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
|
||||||
await sessionsStore.exportSessionAsHtml("session-1");
|
|
||||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
|
||||||
expect(content).toContain("<script>alert("xss")</script>");
|
|
||||||
expect(content).not.toContain("<script>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escapes HTML in tool names", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
setMockInvokeResult(
|
|
||||||
"load_session",
|
|
||||||
makeSavedSession({
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
type: "tool_use",
|
|
||||||
content: "input",
|
|
||||||
timestamp: "2026-03-03T10:00:00Z",
|
|
||||||
tool_name: "<dangerous>",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
|
||||||
await sessionsStore.exportSessionAsHtml("session-1");
|
|
||||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
|
||||||
expect(content).toContain("<dangerous>");
|
|
||||||
expect(content).not.toContain("<dangerous>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes metadata when includeMetadata is false", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
|
||||||
await sessionsStore.exportSessionAsHtml("session-1", false);
|
|
||||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
|
||||||
expect(content).not.toContain('class="metadata"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when session not found", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", null);
|
|
||||||
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when user cancels dialog", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue(null);
|
|
||||||
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false on error", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", new Error("Load failed"));
|
|
||||||
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when writeTextFile throws", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session.html");
|
|
||||||
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
|
||||||
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - exportSessionAsPdf (tests generatePrintableHtml)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates printable HTML and opens it in the browser", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { openPath } = await import("@tauri-apps/plugin-opener");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
|
||||||
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(true);
|
|
||||||
expect(openPath).toHaveBeenCalledWith("/output/session-print.html");
|
|
||||||
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
|
||||||
expect(content).toContain("<!DOCTYPE html>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes metadata when includeMetadata is false", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
|
||||||
await sessionsStore.exportSessionAsPdf("session-1", false);
|
|
||||||
expect(vi.mocked(writeTextFile).mock.calls[0][1]).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when session not found", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", null);
|
|
||||||
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when user cancels dialog", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue(null);
|
|
||||||
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false on error", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", new Error("Load failed"));
|
|
||||||
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when writeTextFile throws", async () => {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
setMockInvokeResult("load_session", makeSavedSession());
|
|
||||||
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
|
||||||
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
|
||||||
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sessionsStore - importSession", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("imports session from JSON file and returns true", async () => {
|
|
||||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
vi.mocked(open).mockResolvedValue("/input/session.json");
|
|
||||||
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ session: makeSavedSession() }));
|
|
||||||
setMockInvokeResult("save_session", undefined);
|
|
||||||
setMockInvokeResult("list_sessions", []);
|
|
||||||
expect(await sessionsStore.importSession()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when user cancels file dialog", async () => {
|
|
||||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
vi.mocked(open).mockResolvedValue(null);
|
|
||||||
expect(await sessionsStore.importSession()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when file has invalid format", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
|
||||||
vi.mocked(open).mockResolvedValue("/input/bad.json");
|
|
||||||
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ not_a_session: true }));
|
|
||||||
expect(await sessionsStore.importSession()).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false on error", async () => {
|
|
||||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
vi.mocked(open).mockRejectedValue(new Error("Dialog failed"));
|
|
||||||
expect(await sessionsStore.importSession()).toBe(false);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+3
-14
@@ -11,14 +11,7 @@
|
|||||||
updateDiscordRpc,
|
updateDiscordRpc,
|
||||||
setSkipNextGreeting,
|
setSkipNextGreeting,
|
||||||
} from "$lib/tauri";
|
} from "$lib/tauri";
|
||||||
import {
|
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
||||||
configStore,
|
|
||||||
applyTheme,
|
|
||||||
applyFontSize,
|
|
||||||
applyCustomFont,
|
|
||||||
applyCustomUiFont,
|
|
||||||
isCompactMode,
|
|
||||||
} from "$lib/stores/config";
|
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
@@ -461,8 +454,6 @@
|
|||||||
const config = configStore.getConfig();
|
const config = configStore.getConfig();
|
||||||
applyTheme(config.theme, config.custom_theme_colors);
|
applyTheme(config.theme, config.custom_theme_colors);
|
||||||
applyFontSize(config.font_size);
|
applyFontSize(config.font_size);
|
||||||
await applyCustomFont(config.custom_font_path, config.custom_font_family);
|
|
||||||
await applyCustomUiFont(config.custom_ui_font_path, config.custom_ui_font_family);
|
|
||||||
|
|
||||||
// Apply always-on-top setting
|
// Apply always-on-top setting
|
||||||
if (config.always_on_top) {
|
if (config.always_on_top) {
|
||||||
@@ -602,15 +593,13 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.app-container {
|
.app-container {
|
||||||
font-family: var(
|
font-family:
|
||||||
--ui-font-family,
|
|
||||||
"Inter",
|
"Inter",
|
||||||
-apple-system,
|
-apple-system,
|
||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
"Segoe UI",
|
"Segoe UI",
|
||||||
Roboto,
|
Roboto,
|
||||||
sans-serif
|
sans-serif;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-panel {
|
.character-panel {
|
||||||
|
|||||||
+1
-4
@@ -17,10 +17,7 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
if (command in mockInvokeResults) {
|
if (command in mockInvokeResults) {
|
||||||
const result = mockInvokeResults[command];
|
const result = mockInvokeResults[command];
|
||||||
if (result instanceof Error) {
|
if (result instanceof Error) {
|
||||||
const err = result;
|
return Promise.reject(result);
|
||||||
return Promise.resolve().then(() => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return Promise.resolve(result);
|
return Promise.resolve(result);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user