generated from nhcarrigan/template
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9af61a4a29
|
|||
| fa906684c2 |
@@ -39,7 +39,7 @@ git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git <
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
All new features, fixes, and significant changes should include tests whenever possible:
|
||||
**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.
|
||||
|
||||
- **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
|
||||
@@ -137,16 +137,16 @@ describe("FeatureName", () => {
|
||||
});
|
||||
```
|
||||
|
||||
### Adding Tests for New Features
|
||||
### Adding Tests for All Changes
|
||||
|
||||
When developing new features, always add corresponding tests:
|
||||
Every change — features, bug fixes, refactors — must include tests:
|
||||
|
||||
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
||||
2. **During implementation**: Write tests alongside the code
|
||||
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
||||
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
||||
|
||||
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
|
||||
**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!
|
||||
|
||||
## Quality Assurance
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -1648,7 +1648,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hikari-desktop"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
|
||||
+128
-8
@@ -862,6 +862,26 @@ pub async fn read_file_content(path: String) -> Result<String, String> {
|
||||
.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)
|
||||
#[allow(dead_code)]
|
||||
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
||||
@@ -1353,9 +1373,29 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct MemoryFileInfo {
|
||||
pub path: String,
|
||||
pub heading: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct MemoryFilesResponse {
|
||||
pub files: Vec<String>,
|
||||
pub files: Vec<MemoryFileInfo>,
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -1398,12 +1438,19 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let files: Vec<String> = stdout
|
||||
let paths: Vec<String> = stdout
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| line.trim().to_string())
|
||||
.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 })
|
||||
}
|
||||
|
||||
@@ -1425,10 +1472,13 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
||||
return Ok(MemoryFilesResponse { files: Vec::new() });
|
||||
}
|
||||
|
||||
let mut memory_files = Vec::new();
|
||||
let mut memory_paths = Vec::new();
|
||||
|
||||
// Recursively find all memory directories
|
||||
fn find_memory_files(dir: &std::path::Path, files: &mut Vec<String>) -> std::io::Result<()> {
|
||||
fn find_memory_files(
|
||||
dir: &std::path::Path,
|
||||
files: &mut Vec<String>,
|
||||
) -> std::io::Result<()> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1461,16 +1511,25 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) {
|
||||
if let Err(e) = find_memory_files(&projects_dir, &mut memory_paths) {
|
||||
return Err(format!("Failed to list memory files: {}", e));
|
||||
}
|
||||
|
||||
// Sort files alphabetically
|
||||
memory_files.sort();
|
||||
memory_paths.sort();
|
||||
|
||||
Ok(MemoryFilesResponse {
|
||||
files: memory_files,
|
||||
// Read first heading from each file
|
||||
let files = memory_paths
|
||||
.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]
|
||||
@@ -2902,4 +2961,65 @@ gitea: gitea-mcp -t stdio (STDIO) - âś“ Connected"#;
|
||||
assert_eq!(servers[0].name, "asana");
|
||||
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,6 +144,20 @@ pub struct HikariConfig {
|
||||
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -183,6 +197,10 @@ impl Default for HikariConfig {
|
||||
background_image_path: None,
|
||||
background_image_opacity: 0.3,
|
||||
show_thinking_blocks: false,
|
||||
custom_font_path: None,
|
||||
custom_font_family: None,
|
||||
custom_ui_font_path: None,
|
||||
custom_ui_font_family: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,6 +316,10 @@ mod tests {
|
||||
assert!(!config.disable_1m_context);
|
||||
assert!(config.trusted_workspaces.is_empty());
|
||||
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]
|
||||
@@ -337,6 +359,10 @@ mod tests {
|
||||
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
||||
background_image_opacity: 0.25,
|
||||
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();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+1
-5
@@ -154,11 +154,7 @@ body {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
"Segoe UI",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
type Theme,
|
||||
type CustomThemeColors,
|
||||
applyFontSize,
|
||||
applyCustomFont,
|
||||
applyCustomUiFont,
|
||||
applyCustomThemeColors,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
@@ -60,9 +62,19 @@
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
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 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 {
|
||||
is_logged_in: boolean;
|
||||
@@ -88,6 +100,10 @@
|
||||
|
||||
configStore.config.subscribe((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) => {
|
||||
@@ -937,6 +953,130 @@
|
||||
</p>
|
||||
</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 -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
|
||||
@@ -1011,7 +1011,7 @@ User: ${formattedMessage}`;
|
||||
: "Connect to Claude first..."}
|
||||
disabled={isSubmitting}
|
||||
rows={1}
|
||||
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
|
||||
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
||||
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
|
||||
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
@@ -3,17 +3,22 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
|
||||
let memoryFiles: string[] = $state([]);
|
||||
interface MemoryFileInfo {
|
||||
path: string;
|
||||
heading: string | null;
|
||||
}
|
||||
|
||||
interface MemoryFilesResponse {
|
||||
files: MemoryFileInfo[];
|
||||
}
|
||||
|
||||
let memoryFiles: MemoryFileInfo[] = $state([]);
|
||||
let selectedFile: string | null = $state(null);
|
||||
let fileContent: string = $state("");
|
||||
let isLoading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let isPanelOpen = $state(false);
|
||||
|
||||
interface MemoryFilesResponse {
|
||||
files: string[];
|
||||
}
|
||||
|
||||
async function loadMemoryFiles() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
@@ -49,6 +54,10 @@
|
||||
return path.split("/").pop() || path;
|
||||
}
|
||||
|
||||
function getDisplayName(file: MemoryFileInfo): string {
|
||||
return file.heading ?? getFileName(file.path);
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
isPanelOpen = !isPanelOpen;
|
||||
if (isPanelOpen && memoryFiles.length === 0) {
|
||||
@@ -151,11 +160,12 @@
|
||||
{:else}
|
||||
<div class="panel-layout">
|
||||
<div class="file-list">
|
||||
{#each memoryFiles as file (file)}
|
||||
{#each memoryFiles as file (file.path)}
|
||||
<button
|
||||
class="file-item"
|
||||
class:active={selectedFile === file}
|
||||
onclick={() => loadFileContent(file)}
|
||||
class:active={selectedFile === file.path}
|
||||
onclick={() => loadFileContent(file.path)}
|
||||
title={getFileName(file.path)}
|
||||
>
|
||||
<svg
|
||||
class="file-icon"
|
||||
@@ -171,7 +181,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"
|
||||
/>
|
||||
</svg>
|
||||
<span class="file-name">{getFileName(file)}</span>
|
||||
<span class="file-name">{getDisplayName(file)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -179,7 +189,12 @@
|
||||
<div class="file-viewer">
|
||||
{#if selectedFile && fileContent}
|
||||
<div class="viewer-header">
|
||||
<h4>{getFileName(selectedFile)}</h4>
|
||||
{#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
|
||||
<h4>{getDisplayName(activeFile)}</h4>
|
||||
{#if activeFile.heading}
|
||||
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="viewer-content">
|
||||
<Markdown content={fileContent} />
|
||||
@@ -438,6 +453,13 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.viewer-filename {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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,6 +111,10 @@
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
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);
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
bind:this={terminalElement}
|
||||
onscroll={handleScroll}
|
||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
||||
style="font-size: var(--terminal-font-size, 14px);"
|
||||
style="font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
||||
>
|
||||
{#if lines.length === 0}
|
||||
<div class="terminal-waiting italic">
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
maskPaths,
|
||||
clampFontSize,
|
||||
applyFontSize,
|
||||
applyCustomFont,
|
||||
applyCustomUiFont,
|
||||
applyTheme,
|
||||
applyCustomThemeColors,
|
||||
clearCustomThemeColors,
|
||||
@@ -21,12 +23,17 @@ import {
|
||||
type CustomThemeColors,
|
||||
} from "./config";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
// Mock Tauri APIs
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("config store", () => {
|
||||
describe("font size constants", () => {
|
||||
it("has correct MIN_FONT_SIZE", () => {
|
||||
@@ -206,6 +213,10 @@ describe("config store", () => {
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
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");
|
||||
@@ -258,6 +269,10 @@ describe("config store", () => {
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
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();
|
||||
@@ -809,6 +824,10 @@ describe("config store", () => {
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
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);
|
||||
@@ -1133,4 +1152,323 @@ describe("config store", () => {
|
||||
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,5 +1,6 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||
export type BudgetAction = "warn" | "block";
|
||||
@@ -58,6 +59,12 @@ export interface HikariConfig {
|
||||
// Background image settings
|
||||
background_image_path: string | null;
|
||||
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 = {
|
||||
@@ -104,6 +111,10 @@ const defaultConfig: HikariConfig = {
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
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() {
|
||||
@@ -240,6 +251,16 @@ function createConfigStore() {
|
||||
setCompactMode: async (enabled: boolean) => {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,6 +326,99 @@ export function clampFontSize(size: number): number {
|
||||
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 const configStore = createConfigStore();
|
||||
|
||||
+14
-3
@@ -11,7 +11,14 @@
|
||||
updateDiscordRpc,
|
||||
setSkipNextGreeting,
|
||||
} from "$lib/tauri";
|
||||
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
||||
import {
|
||||
configStore,
|
||||
applyTheme,
|
||||
applyFontSize,
|
||||
applyCustomFont,
|
||||
applyCustomUiFont,
|
||||
isCompactMode,
|
||||
} from "$lib/stores/config";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
@@ -454,6 +461,8 @@
|
||||
const config = configStore.getConfig();
|
||||
applyTheme(config.theme, config.custom_theme_colors);
|
||||
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
|
||||
if (config.always_on_top) {
|
||||
@@ -593,13 +602,15 @@
|
||||
|
||||
<style>
|
||||
.app-container {
|
||||
font-family:
|
||||
font-family: var(
|
||||
--ui-font-family,
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
sans-serif
|
||||
);
|
||||
}
|
||||
|
||||
.character-panel {
|
||||
|
||||
Reference in New Issue
Block a user