generated from nhcarrigan/template
feat: add multiple productivity features and UI enhancements (#68)
## Summary This PR adds a collection of productivity features and UI enhancements to improve the Hikari Desktop experience: ### New Features - **Clipboard History** (#25) - Track and manage copied code snippets with language detection, search, filtering, and pinning - **Quick Actions Panel** (#15) - Buttons for common quick actions like "Review PR", "Run tests", "Explain file", with customizable actions - **Git Integration Panel** (#24) - View current branch, changed/staged files, quick git actions (commit, push, pull), and branch management - **Session Import/Export** (#8) - Export conversations to JSON and import previously saved sessions - **Snippet Library** (#22) - Save and reuse common prompts with categories and quick insert - **Session History** (#14) - Auto-save conversations with browsable history and search - **High Contrast Mode** (#20) - Accessibility theme with improved visibility - **Minimize to System Tray** (#11) - System tray support with right-click menu ### UI Enhancements - Trans-pride gradient theme applied across UI elements - Copy button added to code blocks - Linter formatting and eslint-disable comments for cleaner code ## Closes Closes #8 Closes #11 Closes #14 Closes #15 Closes #20 Closes #22 Closes #24 Closes #25 Closes #34 Closes #35 Closes #36 Closes #37 Closes #69 Closes #70 ## Test Plan - [ ] Verify clipboard history captures code from code block copy buttons - [ ] Verify clipboard history captures manually selected text from terminal - [ ] Test snippet library CRUD operations and insertion - [ ] Test quick actions panel with default and custom actions - [ ] Test git panel shows correct status, branch, and performs git operations - [ ] Test session history auto-save and restore - [ ] Test session import/export roundtrip - [ ] Verify high contrast mode provides adequate contrast - [ ] Test minimize to tray functionality and tray menu - [ ] Verify trans-pride gradient theme displays correctly in all themes --- *✨ This PR was created with help from Hikari~ 🌸* Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #68 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #68.
This commit is contained in:
+1207
-83
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
||||
// Clipboard history store for managing copied code snippets
|
||||
// Implements issue #25 - Clipboard History feature
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writable, derived } from "svelte/store";
|
||||
|
||||
export interface ClipboardEntry {
|
||||
id: string;
|
||||
content: string;
|
||||
language: string | null;
|
||||
source: string | null;
|
||||
timestamp: string;
|
||||
is_pinned: boolean;
|
||||
}
|
||||
|
||||
// Create stores
|
||||
const entriesStore = writable<ClipboardEntry[]>([]);
|
||||
const searchQueryStore = writable<string>("");
|
||||
const languageFilterStore = writable<string | null>(null);
|
||||
const isLoadingStore = writable<boolean>(false);
|
||||
|
||||
// Derived store for filtered entries
|
||||
const filteredEntriesStore = derived(
|
||||
[entriesStore, searchQueryStore, languageFilterStore],
|
||||
([$entries, $searchQuery, $languageFilter]) => {
|
||||
let filtered = $entries;
|
||||
|
||||
// Filter by language
|
||||
if ($languageFilter) {
|
||||
filtered = filtered.filter((e) => e.language === $languageFilter);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if ($searchQuery) {
|
||||
const query = $searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.content.toLowerCase().includes(query) ||
|
||||
e.language?.toLowerCase().includes(query) ||
|
||||
e.source?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
);
|
||||
|
||||
// Derived store for unique languages
|
||||
const languagesStore = derived(entriesStore, ($entries) => {
|
||||
const languages = new Set<string>();
|
||||
for (const entry of $entries) {
|
||||
if (entry.language) {
|
||||
languages.add(entry.language);
|
||||
}
|
||||
}
|
||||
return Array.from(languages).sort();
|
||||
});
|
||||
|
||||
// Helper function to detect language from content
|
||||
function detectLanguage(content: string): string | null {
|
||||
// Common language patterns
|
||||
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;
|
||||
}
|
||||
|
||||
// Store actions
|
||||
async function loadEntries(): Promise<void> {
|
||||
isLoadingStore.set(true);
|
||||
try {
|
||||
const entries = await invoke<ClipboardEntry[]>("list_clipboard_entries", {
|
||||
language: null,
|
||||
});
|
||||
entriesStore.set(entries);
|
||||
} catch (error) {
|
||||
console.error("Failed to load clipboard entries:", error);
|
||||
} finally {
|
||||
isLoadingStore.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureClipboard(
|
||||
content: string,
|
||||
language?: string | null,
|
||||
source?: string | null
|
||||
): Promise<ClipboardEntry | null> {
|
||||
try {
|
||||
// Auto-detect language if not provided
|
||||
const detectedLanguage = language ?? detectLanguage(content);
|
||||
|
||||
const entry = await invoke<ClipboardEntry>("capture_clipboard", {
|
||||
content,
|
||||
language: detectedLanguage,
|
||||
source: source ?? null,
|
||||
});
|
||||
|
||||
// Reload entries to get the updated list
|
||||
await loadEntries();
|
||||
|
||||
return entry;
|
||||
} catch (error) {
|
||||
console.error("Failed to capture clipboard:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEntry(id: string): Promise<void> {
|
||||
try {
|
||||
await invoke("delete_clipboard_entry", { id });
|
||||
entriesStore.update((entries) => entries.filter((e) => e.id !== id));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete clipboard entry:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePin(id: string): Promise<void> {
|
||||
try {
|
||||
const updated = await invoke<ClipboardEntry>("toggle_pin_clipboard_entry", { id });
|
||||
entriesStore.update((entries) =>
|
||||
entries
|
||||
.map((e) => (e.id === id ? updated : e))
|
||||
.sort((a, b) => {
|
||||
// Pinned first, then by timestamp
|
||||
if (a.is_pinned && !b.is_pinned) return -1;
|
||||
if (!a.is_pinned && b.is_pinned) return 1;
|
||||
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle pin:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearHistory(): Promise<void> {
|
||||
try {
|
||||
await invoke("clear_clipboard_history");
|
||||
entriesStore.update((entries) => entries.filter((e) => e.is_pinned));
|
||||
} catch (error) {
|
||||
console.error("Failed to clear clipboard history:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLanguage(id: string, language: string | null): Promise<void> {
|
||||
try {
|
||||
const updated = await invoke<ClipboardEntry>("update_clipboard_language", {
|
||||
id,
|
||||
language,
|
||||
});
|
||||
entriesStore.update((entries) => entries.map((e) => (e.id === id ? updated : e)));
|
||||
} catch (error) {
|
||||
console.error("Failed to update language:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function setSearchQuery(query: string): void {
|
||||
searchQueryStore.set(query);
|
||||
}
|
||||
|
||||
function setLanguageFilter(language: string | null): void {
|
||||
languageFilterStore.set(language);
|
||||
}
|
||||
|
||||
// Copy entry content to clipboard
|
||||
async function copyToClipboard(content: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Format timestamp for display
|
||||
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();
|
||||
}
|
||||
|
||||
// Export the store
|
||||
export const clipboardStore = {
|
||||
subscribe: entriesStore.subscribe,
|
||||
entries: entriesStore,
|
||||
filteredEntries: filteredEntriesStore,
|
||||
languages: languagesStore,
|
||||
searchQuery: searchQueryStore,
|
||||
languageFilter: languageFilterStore,
|
||||
isLoading: isLoadingStore,
|
||||
loadEntries,
|
||||
captureClipboard,
|
||||
deleteEntry,
|
||||
togglePin,
|
||||
clearHistory,
|
||||
updateLanguage,
|
||||
setSearchQuery,
|
||||
setLanguageFilter,
|
||||
copyToClipboard,
|
||||
formatTimestamp,
|
||||
detectLanguage,
|
||||
};
|
||||
+137
-6
@@ -1,7 +1,18 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||
|
||||
export interface CustomThemeColors {
|
||||
bg_primary: string | null;
|
||||
bg_secondary: string | null;
|
||||
bg_terminal: string | null;
|
||||
accent_primary: string | null;
|
||||
accent_secondary: string | null;
|
||||
text_primary: string | null;
|
||||
text_secondary: string | null;
|
||||
border_color: string | null;
|
||||
}
|
||||
|
||||
export interface HikariConfig {
|
||||
model: string | null;
|
||||
@@ -15,9 +26,17 @@ export interface HikariConfig {
|
||||
notifications_enabled: boolean;
|
||||
notification_volume: number;
|
||||
always_on_top: boolean;
|
||||
minimize_to_tray: boolean;
|
||||
update_checks_enabled: boolean;
|
||||
character_panel_width: number | null;
|
||||
font_size: number;
|
||||
streamer_mode: boolean;
|
||||
streamer_hide_paths: boolean;
|
||||
compact_mode: boolean;
|
||||
profile_name: string | null;
|
||||
profile_avatar_path: string | null;
|
||||
profile_bio: string | null;
|
||||
custom_theme_colors: CustomThemeColors;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -32,9 +51,26 @@ const defaultConfig: HikariConfig = {
|
||||
notifications_enabled: true,
|
||||
notification_volume: 0.7,
|
||||
always_on_top: false,
|
||||
minimize_to_tray: false,
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: null,
|
||||
profile_avatar_path: null,
|
||||
profile_bio: null,
|
||||
custom_theme_colors: {
|
||||
bg_primary: null,
|
||||
bg_secondary: null,
|
||||
bg_terminal: null,
|
||||
accent_primary: null,
|
||||
accent_secondary: null,
|
||||
text_primary: null,
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
@@ -90,9 +126,24 @@ function createConfigStore() {
|
||||
closeSidebar: () => isSidebarOpen.set(false),
|
||||
toggleSidebar: () => isSidebarOpen.update((open) => !open),
|
||||
|
||||
setTheme: async (theme: Theme) => {
|
||||
await updateConfig({ theme });
|
||||
applyTheme(theme);
|
||||
setTheme: async (theme: Theme, customColors?: CustomThemeColors) => {
|
||||
const updates: Partial<HikariConfig> = { theme };
|
||||
if (customColors) {
|
||||
updates.custom_theme_colors = customColors;
|
||||
}
|
||||
await updateConfig(updates);
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
applyTheme(theme, currentConfig.custom_theme_colors);
|
||||
},
|
||||
|
||||
setCustomThemeColors: async (colors: CustomThemeColors) => {
|
||||
await updateConfig({ custom_theme_colors: colors });
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
if (currentConfig.theme === "custom") {
|
||||
applyCustomThemeColors(colors);
|
||||
}
|
||||
},
|
||||
|
||||
setFontSize: async (size: number) => {
|
||||
@@ -143,15 +194,72 @@ function createConfigStore() {
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
return currentConfig;
|
||||
},
|
||||
|
||||
toggleStreamerMode: async () => {
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
|
||||
},
|
||||
|
||||
toggleCompactMode: async () => {
|
||||
let currentConfig: HikariConfig = defaultConfig;
|
||||
config.subscribe((c) => (currentConfig = c))();
|
||||
await updateConfig({ compact_mode: !currentConfig.compact_mode });
|
||||
},
|
||||
|
||||
setCompactMode: async (enabled: boolean) => {
|
||||
await updateConfig({ compact_mode: enabled });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme) {
|
||||
export function applyTheme(theme: Theme, customColors?: CustomThemeColors) {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
// For custom theme, we use dark as the base and override with custom colors
|
||||
document.documentElement.setAttribute("data-theme", theme === "custom" ? "dark" : theme);
|
||||
|
||||
// Clear any previously applied custom colors
|
||||
clearCustomThemeColors();
|
||||
|
||||
// Apply custom colors if theme is custom
|
||||
if (theme === "custom" && customColors) {
|
||||
applyCustomThemeColors(customColors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyCustomThemeColors(colors: CustomThemeColors) {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const root = document.documentElement;
|
||||
if (colors.bg_primary) root.style.setProperty("--bg-primary", colors.bg_primary);
|
||||
if (colors.bg_secondary) root.style.setProperty("--bg-secondary", colors.bg_secondary);
|
||||
if (colors.bg_terminal) root.style.setProperty("--bg-terminal", colors.bg_terminal);
|
||||
if (colors.accent_primary) root.style.setProperty("--accent-primary", colors.accent_primary);
|
||||
if (colors.accent_secondary)
|
||||
root.style.setProperty("--accent-secondary", colors.accent_secondary);
|
||||
if (colors.text_primary) root.style.setProperty("--text-primary", colors.text_primary);
|
||||
if (colors.text_secondary) root.style.setProperty("--text-secondary", colors.text_secondary);
|
||||
if (colors.border_color) root.style.setProperty("--border-color", colors.border_color);
|
||||
}
|
||||
|
||||
export function clearCustomThemeColors() {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const customProperties = [
|
||||
"--bg-primary",
|
||||
"--bg-secondary",
|
||||
"--bg-terminal",
|
||||
"--accent-primary",
|
||||
"--accent-secondary",
|
||||
"--text-primary",
|
||||
"--text-secondary",
|
||||
"--border-color",
|
||||
];
|
||||
customProperties.forEach((prop) => root.style.removeProperty(prop));
|
||||
}
|
||||
|
||||
const MIN_FONT_SIZE = 10;
|
||||
const MAX_FONT_SIZE = 24;
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
@@ -172,3 +280,26 @@ export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
||||
export const configStore = createConfigStore();
|
||||
|
||||
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
|
||||
|
||||
export const isStreamerMode = derived(configStore.config, ($config) => $config.streamer_mode);
|
||||
export const isCompactMode = derived(configStore.config, ($config) => $config.compact_mode);
|
||||
export const shouldHidePaths = derived(
|
||||
configStore.config,
|
||||
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
||||
);
|
||||
|
||||
/**
|
||||
* Masks file paths in text when streamer mode with hide paths is enabled.
|
||||
* Replaces username portion of paths with asterisks.
|
||||
*/
|
||||
export function maskPaths(text: string, hidePaths: boolean): string {
|
||||
if (!hidePaths) return text;
|
||||
|
||||
// Match Unix paths like /home/username/... or /Users/username/...
|
||||
// and Windows paths like C:\Users\username\...
|
||||
return text
|
||||
.replace(/\/home\/([^/\s]+)/g, "/home/****")
|
||||
.replace(/\/Users\/([^/\s]+)/g, "/Users/****")
|
||||
.replace(/C:\\Users\\([^\\\s]+)/gi, "C:\\Users\\****")
|
||||
.replace(/~\//g, "****/");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import { cleanupConversationTracking } from "$lib/tauri";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { sessionsStore } from "$lib/stores/sessions";
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
@@ -287,6 +288,9 @@ function createConversationsStore() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel any pending auto-save for this conversation
|
||||
sessionsStore.cancelAutoSave(id);
|
||||
|
||||
// Clean up tracking for this conversation (including temp files)
|
||||
await cleanupConversationTracking(id);
|
||||
|
||||
@@ -434,6 +438,8 @@ function createConversationsStore() {
|
||||
if (conv) {
|
||||
conv.terminalLines.push(line);
|
||||
conv.lastActivityAt = new Date();
|
||||
// Schedule auto-save for this conversation
|
||||
sessionsStore.scheduleAutoSave(conv);
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
@@ -462,6 +468,8 @@ function createConversationsStore() {
|
||||
if (conv) {
|
||||
conv.terminalLines.push(line);
|
||||
conv.lastActivityAt = new Date();
|
||||
// Schedule auto-save for this conversation
|
||||
sessionsStore.scheduleAutoSave(conv);
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface QuickAction {
|
||||
id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
icon: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function createQuickActionsStore() {
|
||||
const actions = writable<QuickAction[]>([]);
|
||||
const isLoading = writable(false);
|
||||
|
||||
async function loadQuickActions(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const actionList = await invoke<QuickAction[]>("list_quick_actions");
|
||||
actions.set(actionList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick actions:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickAction(action: QuickAction): Promise<boolean> {
|
||||
try {
|
||||
await invoke("save_quick_action", { action });
|
||||
await loadQuickActions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save quick action:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createQuickAction(name: string, prompt: string, icon: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const action: QuickAction = {
|
||||
id: `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
name,
|
||||
prompt,
|
||||
icon,
|
||||
is_default: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
return saveQuickAction(action);
|
||||
}
|
||||
|
||||
async function updateQuickAction(
|
||||
id: string,
|
||||
name: string,
|
||||
prompt: string,
|
||||
icon: string
|
||||
): Promise<boolean> {
|
||||
const currentActions = await invoke<QuickAction[]>("list_quick_actions");
|
||||
const existing = currentActions.find((a) => a.id === id);
|
||||
|
||||
if (!existing) {
|
||||
console.error("Quick action not found for update");
|
||||
return false;
|
||||
}
|
||||
|
||||
const updated: QuickAction = {
|
||||
...existing,
|
||||
name,
|
||||
prompt,
|
||||
icon,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return saveQuickAction(updated);
|
||||
}
|
||||
|
||||
async function deleteQuickAction(actionId: string): Promise<boolean> {
|
||||
try {
|
||||
await invoke("delete_quick_action", { actionId });
|
||||
await loadQuickActions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete quick action:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDefaults(): Promise<boolean> {
|
||||
try {
|
||||
await invoke("reset_default_quick_actions");
|
||||
await loadQuickActions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to reset default quick actions:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actions: { subscribe: actions.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
loadQuickActions,
|
||||
saveQuickAction,
|
||||
createQuickAction,
|
||||
updateQuickAction,
|
||||
deleteQuickAction,
|
||||
resetDefaults,
|
||||
};
|
||||
}
|
||||
|
||||
export const quickActionsStore = createQuickActionsStore();
|
||||
@@ -0,0 +1,708 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Conversation } from "./conversations";
|
||||
import { writeTextFile, readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { openPath } from "@tauri-apps/plugin-opener";
|
||||
|
||||
// Debounce delay for auto-save (in milliseconds)
|
||||
const AUTO_SAVE_DEBOUNCE_MS = 2000;
|
||||
|
||||
export interface SavedMessage {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
tool_name?: string;
|
||||
}
|
||||
|
||||
export interface SavedSession {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_activity_at: string;
|
||||
working_directory: string;
|
||||
message_count: number;
|
||||
preview: string;
|
||||
messages: SavedMessage[];
|
||||
}
|
||||
|
||||
export interface SessionListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_activity_at: string;
|
||||
working_directory: string;
|
||||
message_count: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function generateHtmlExport(session: SavedSession, includeMetadata: boolean): string {
|
||||
const styles = `
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-code: #1e1e2e;
|
||||
--accent-primary: #e94560;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--border-color: #2a2a4a;
|
||||
--terminal-user: #22d3ee;
|
||||
--terminal-tool: #c084fc;
|
||||
--terminal-error: #f87171;
|
||||
--hljs-keyword: #f472b6;
|
||||
--hljs-string: #a3e635;
|
||||
--hljs-number: #fbbf24;
|
||||
--hljs-comment: #6b7280;
|
||||
--hljs-function: #c084fc;
|
||||
--hljs-type: #22d3ee;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { color: var(--accent-primary); margin-bottom: 1rem; }
|
||||
.metadata {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.metadata p { color: var(--text-secondary); margin: 0.25rem 0; }
|
||||
.metadata strong { color: var(--text-primary); }
|
||||
hr { border: none; border-top: 1px solid var(--border-color); margin: 1.5rem 0; }
|
||||
.message {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 4px solid var(--border-color);
|
||||
}
|
||||
.message.user { border-left-color: var(--terminal-user); }
|
||||
.message.assistant { border-left-color: var(--accent-primary); }
|
||||
.message.tool_use { border-left-color: var(--terminal-tool); }
|
||||
.message.tool_result { border-left-color: var(--hljs-string); }
|
||||
.message.error { border-left-color: var(--terminal-error); }
|
||||
.message.system { border-left-color: var(--text-secondary); }
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.message-type {
|
||||
font-weight: bold;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.message-type.user { color: var(--terminal-user); }
|
||||
.message-type.assistant { color: var(--accent-primary); }
|
||||
.message-type.tool_use { color: var(--terminal-tool); }
|
||||
.message-type.tool_result { color: var(--hljs-string); }
|
||||
.message-type.error { color: var(--terminal-error); }
|
||||
.message-type.system { color: var(--text-secondary); }
|
||||
.timestamp { color: var(--text-secondary); font-size: 0.85rem; }
|
||||
.tool-name { color: var(--terminal-tool); font-size: 0.9rem; }
|
||||
.message-content {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
pre {
|
||||
background: var(--bg-code);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
code {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg-primary: #f8f9fa;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-code: #f5f5f5;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: #5a5a7a;
|
||||
--border-color: #d0d0e0;
|
||||
--terminal-user: #0891b2;
|
||||
--terminal-tool: #7c3aed;
|
||||
--terminal-error: #dc2626;
|
||||
--hljs-keyword: #d946ef;
|
||||
--hljs-string: #16a34a;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
let messagesHtml = "";
|
||||
for (const message of session.messages) {
|
||||
const timestamp = new Date(message.timestamp).toLocaleString();
|
||||
const typeLabel =
|
||||
message.type === "tool_use"
|
||||
? "Tool Use"
|
||||
: message.type === "tool_result"
|
||||
? "Tool Result"
|
||||
: message.type.charAt(0).toUpperCase() + message.type.slice(1);
|
||||
|
||||
messagesHtml += `
|
||||
<div class="message ${message.type}">
|
||||
<div class="message-header">
|
||||
<span class="message-type ${message.type}">${typeLabel}</span>
|
||||
<span class="timestamp">${timestamp}</span>
|
||||
</div>
|
||||
${message.tool_name ? `<div class="tool-name">🔧 ${escapeHtml(message.tool_name)}</div>` : ""}
|
||||
<div class="message-content">${escapeHtml(message.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const metadataHtml = includeMetadata
|
||||
? `
|
||||
<div class="metadata">
|
||||
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
|
||||
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
|
||||
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
|
||||
<p><strong>Messages:</strong> ${session.message_count}</p>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(session.name)} - Hikari Desktop Export</title>
|
||||
<style>${styles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>💬 ${escapeHtml(session.name)}</h1>
|
||||
${metadataHtml}
|
||||
<hr>
|
||||
${messagesHtml}
|
||||
<div class="footer">
|
||||
<p>✨ Exported from Hikari Desktop</p>
|
||||
<p>Export date: ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function generatePrintableHtml(session: SavedSession, includeMetadata: boolean): string {
|
||||
const printStyles = `
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.5;
|
||||
padding: 0.5in;
|
||||
max-width: 8.5in;
|
||||
margin: 0 auto;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18pt;
|
||||
margin-bottom: 0.25in;
|
||||
border-bottom: 2px solid #333;
|
||||
padding-bottom: 0.1in;
|
||||
}
|
||||
.metadata {
|
||||
background: #f5f5f5;
|
||||
padding: 0.15in;
|
||||
margin-bottom: 0.25in;
|
||||
border: 1px solid #ddd;
|
||||
font-size: 9pt;
|
||||
}
|
||||
.metadata p { margin: 2pt 0; }
|
||||
hr { border: none; border-top: 1px solid #ccc; margin: 0.2in 0; }
|
||||
.message {
|
||||
margin-bottom: 0.15in;
|
||||
padding: 0.1in;
|
||||
border-left: 3px solid #ccc;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.message.user { border-left-color: #0088cc; }
|
||||
.message.assistant { border-left-color: #cc0044; }
|
||||
.message.tool_use { border-left-color: #8844cc; }
|
||||
.message.tool_result { border-left-color: #44cc44; }
|
||||
.message.error { border-left-color: #cc4444; }
|
||||
.message.system { border-left-color: #888; }
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
margin-bottom: 0.05in;
|
||||
border-bottom: 1px dotted #ddd;
|
||||
padding-bottom: 0.03in;
|
||||
}
|
||||
.message-type { font-weight: bold; text-transform: uppercase; }
|
||||
.message-type.user { color: #0066aa; }
|
||||
.message-type.assistant { color: #aa0033; }
|
||||
.message-type.tool_use { color: #6633aa; }
|
||||
.message-type.tool_result { color: #33aa33; }
|
||||
.message-type.error { color: #aa3333; }
|
||||
.tool-name { color: #666; font-size: 9pt; font-style: italic; }
|
||||
.message-content {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 9pt;
|
||||
background: #fafafa;
|
||||
padding: 0.05in;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
color: #999;
|
||||
margin-top: 0.25in;
|
||||
padding-top: 0.1in;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
.print-instructions {
|
||||
background: #ffffcc;
|
||||
border: 1px solid #cccc00;
|
||||
padding: 0.15in;
|
||||
margin-bottom: 0.25in;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.print-instructions h2 { font-size: 12pt; margin-bottom: 0.1in; }
|
||||
.print-instructions ol { margin-left: 0.2in; }
|
||||
.print-instructions li { margin: 0.05in 0; }
|
||||
@media print {
|
||||
.print-instructions { display: none; }
|
||||
body { padding: 0; }
|
||||
}
|
||||
@page { margin: 0.5in; }
|
||||
`;
|
||||
|
||||
let messagesHtml = "";
|
||||
for (const message of session.messages) {
|
||||
const timestamp = new Date(message.timestamp).toLocaleString();
|
||||
const typeLabel =
|
||||
message.type === "tool_use"
|
||||
? "Tool"
|
||||
: message.type === "tool_result"
|
||||
? "Result"
|
||||
: message.type.charAt(0).toUpperCase() + message.type.slice(1);
|
||||
|
||||
messagesHtml += `
|
||||
<div class="message ${message.type}">
|
||||
<div class="message-header">
|
||||
<span class="message-type ${message.type}">${typeLabel}</span>
|
||||
<span class="timestamp">${timestamp}</span>
|
||||
</div>
|
||||
${message.tool_name ? `<div class="tool-name">${escapeHtml(message.tool_name)}</div>` : ""}
|
||||
<div class="message-content">${escapeHtml(message.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const metadataHtml = includeMetadata
|
||||
? `
|
||||
<div class="metadata">
|
||||
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
|
||||
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
|
||||
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
|
||||
<p><strong>Messages:</strong> ${session.message_count}</p>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(session.name)} - Print to PDF</title>
|
||||
<style>${printStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-instructions">
|
||||
<h2>Print to PDF Instructions</h2>
|
||||
<ol>
|
||||
<li>Press <strong>Ctrl+P</strong> (or <strong>Cmd+P</strong> on Mac) to open the print dialog</li>
|
||||
<li>Select <strong>"Save as PDF"</strong> or <strong>"Microsoft Print to PDF"</strong> as the printer</li>
|
||||
<li>Click <strong>Print</strong> to save your PDF</li>
|
||||
</ol>
|
||||
<p><em>This instruction box will not appear in your PDF.</em></p>
|
||||
</div>
|
||||
<h1>${escapeHtml(session.name)}</h1>
|
||||
${metadataHtml}
|
||||
<hr>
|
||||
${messagesHtml}
|
||||
<div class="footer">
|
||||
<p>Exported from Hikari Desktop | ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function createSessionsStore() {
|
||||
const sessions = writable<SessionListItem[]>([]);
|
||||
const isLoading = writable(false);
|
||||
const searchQuery = writable("");
|
||||
|
||||
// Track pending auto-save timeouts per conversation
|
||||
const pendingAutoSaves = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
async function loadSessions(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const result = await invoke<SessionListItem[]>("list_sessions");
|
||||
sessions.set(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to load sessions:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConversation(conversation: Conversation): Promise<void> {
|
||||
const messages: SavedMessage[] = conversation.terminalLines.map((line) => ({
|
||||
id: line.id,
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
timestamp: line.timestamp.toISOString(),
|
||||
tool_name: line.toolName,
|
||||
}));
|
||||
|
||||
const userAndAssistantMessages = conversation.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
const previewContent =
|
||||
userAndAssistantMessages
|
||||
.slice(0, 3)
|
||||
.map((m) => m.content)
|
||||
.join(" ")
|
||||
.slice(0, 150) + (userAndAssistantMessages.length > 3 ? "..." : "");
|
||||
|
||||
const session: SavedSession = {
|
||||
id: conversation.id,
|
||||
name: conversation.name,
|
||||
created_at: conversation.createdAt.toISOString(),
|
||||
last_activity_at: conversation.lastActivityAt.toISOString(),
|
||||
working_directory: conversation.workingDirectory,
|
||||
message_count: conversation.terminalLines.length,
|
||||
preview: previewContent || "Empty conversation",
|
||||
messages,
|
||||
};
|
||||
|
||||
try {
|
||||
await invoke("save_session", { session });
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to save session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSession(sessionId: string): Promise<SavedSession | null> {
|
||||
try {
|
||||
const session = await invoke<SavedSession | null>("load_session", {
|
||||
sessionId,
|
||||
});
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error("Failed to load session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await invoke("delete_session", { sessionId });
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchSessions(query: string): Promise<void> {
|
||||
searchQuery.set(query);
|
||||
|
||||
if (!query.trim()) {
|
||||
await loadSessions();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const result = await invoke<SessionListItem[]>("search_sessions", {
|
||||
query,
|
||||
});
|
||||
sessions.set(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to search sessions:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllSessions(): Promise<void> {
|
||||
try {
|
||||
await invoke("clear_all_sessions");
|
||||
sessions.set([]);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear sessions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoSave(conversation: Conversation): void {
|
||||
// Don't auto-save empty conversations
|
||||
if (conversation.terminalLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending auto-save for this conversation
|
||||
const existingTimeout = pendingAutoSaves.get(conversation.id);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
|
||||
// Schedule a new auto-save
|
||||
const timeout = setTimeout(async () => {
|
||||
pendingAutoSaves.delete(conversation.id);
|
||||
try {
|
||||
await saveConversation(conversation);
|
||||
} catch (error) {
|
||||
console.error("Auto-save failed:", error);
|
||||
}
|
||||
}, AUTO_SAVE_DEBOUNCE_MS);
|
||||
|
||||
pendingAutoSaves.set(conversation.id, timeout);
|
||||
}
|
||||
|
||||
function cancelAutoSave(conversationId: string): void {
|
||||
const existingTimeout = pendingAutoSaves.get(conversationId);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
pendingAutoSaves.delete(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSessionAsJson(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) {
|
||||
console.error("Session not found for export");
|
||||
return false;
|
||||
}
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.json`,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exportData = {
|
||||
version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
session,
|
||||
};
|
||||
|
||||
await writeTextFile(filePath, JSON.stringify(exportData, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to export session as JSON:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSessionAsMarkdown(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) {
|
||||
console.error("Session not found for export");
|
||||
return false;
|
||||
}
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.md`,
|
||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let markdown = `# ${session.name}\n\n`;
|
||||
markdown += `**Created:** ${new Date(session.created_at).toLocaleString()}\n`;
|
||||
markdown += `**Last Activity:** ${new Date(session.last_activity_at).toLocaleString()}\n`;
|
||||
markdown += `**Working Directory:** ${session.working_directory || "Not set"}\n`;
|
||||
markdown += `**Messages:** ${session.message_count}\n\n`;
|
||||
markdown += `---\n\n`;
|
||||
|
||||
for (const message of session.messages) {
|
||||
const timestamp = new Date(message.timestamp).toLocaleTimeString();
|
||||
if (message.type === "user") {
|
||||
markdown += `## 👤 User (${timestamp})\n\n${message.content}\n\n`;
|
||||
} else if (message.type === "assistant") {
|
||||
markdown += `## 🤖 Assistant (${timestamp})\n\n${message.content}\n\n`;
|
||||
} else if (message.type === "tool_use") {
|
||||
markdown += `### 🔧 Tool: ${message.tool_name || "Unknown"} (${timestamp})\n\n\`\`\`\n${message.content}\n\`\`\`\n\n`;
|
||||
} else if (message.type === "tool_result") {
|
||||
markdown += `### 📋 Result (${timestamp})\n\n\`\`\`\n${message.content}\n\`\`\`\n\n`;
|
||||
} else if (message.type === "system") {
|
||||
markdown += `> **System:** ${message.content}\n\n`;
|
||||
} else if (message.type === "error") {
|
||||
markdown += `> ⚠️ **Error:** ${message.content}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
markdown += `---\n\n*Exported from Hikari Desktop*\n`;
|
||||
|
||||
await writeTextFile(filePath, markdown);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to export session as Markdown:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSessionAsHtml(
|
||||
sessionId: string,
|
||||
includeMetadata: boolean = true
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) {
|
||||
console.error("Session not found for export");
|
||||
return false;
|
||||
}
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.html`,
|
||||
filters: [{ name: "HTML", extensions: ["html"] }],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const html = generateHtmlExport(session, includeMetadata);
|
||||
await writeTextFile(filePath, html);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to export session as HTML:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSessionAsPdf(
|
||||
sessionId: string,
|
||||
includeMetadata: boolean = true
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) {
|
||||
console.error("Session not found for export");
|
||||
return false;
|
||||
}
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}-print.html`,
|
||||
filters: [{ name: "HTML (for PDF printing)", extensions: ["html"] }],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const html = generatePrintableHtml(session, includeMetadata);
|
||||
await writeTextFile(filePath, html);
|
||||
|
||||
// Open the file in the default browser for print-to-PDF
|
||||
await openPath(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to export session for PDF:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importSession(): Promise<boolean> {
|
||||
try {
|
||||
const filePath = await open({
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = await readTextFile(filePath as string);
|
||||
const importData = JSON.parse(content);
|
||||
|
||||
if (!importData.session || !importData.session.id) {
|
||||
console.error("Invalid session file format");
|
||||
return false;
|
||||
}
|
||||
|
||||
const session: SavedSession = importData.session;
|
||||
|
||||
// Generate a new ID to avoid conflicts with existing sessions
|
||||
session.id = `imported-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
session.name = `${session.name} (Imported)`;
|
||||
|
||||
await invoke("save_session", { session });
|
||||
await loadSessions();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to import session:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: { subscribe: sessions.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
searchQuery: { subscribe: searchQuery.subscribe },
|
||||
loadSessions,
|
||||
saveConversation,
|
||||
loadSession,
|
||||
deleteSession,
|
||||
searchSessions,
|
||||
clearAllSessions,
|
||||
scheduleAutoSave,
|
||||
cancelAutoSave,
|
||||
exportSessionAsJson,
|
||||
exportSessionAsMarkdown,
|
||||
exportSessionAsHtml,
|
||||
exportSessionAsPdf,
|
||||
importSession,
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionsStore = createSessionsStore();
|
||||
@@ -0,0 +1,138 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface Snippet {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
category: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function createSnippetsStore() {
|
||||
const snippets = writable<Snippet[]>([]);
|
||||
const categories = writable<string[]>([]);
|
||||
const isLoading = writable(false);
|
||||
const selectedCategory = writable<string | null>(null);
|
||||
|
||||
const filteredSnippets = derived(
|
||||
[snippets, selectedCategory],
|
||||
([$snippets, $selectedCategory]) => {
|
||||
if (!$selectedCategory) {
|
||||
return $snippets;
|
||||
}
|
||||
return $snippets.filter((s) => s.category === $selectedCategory);
|
||||
}
|
||||
);
|
||||
|
||||
async function loadSnippets(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const [snippetList, categoryList] = await Promise.all([
|
||||
invoke<Snippet[]>("list_snippets"),
|
||||
invoke<string[]>("get_snippet_categories"),
|
||||
]);
|
||||
snippets.set(snippetList);
|
||||
categories.set(categoryList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load snippets:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSnippet(snippet: Snippet): Promise<boolean> {
|
||||
try {
|
||||
await invoke("save_snippet", { snippet });
|
||||
await loadSnippets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save snippet:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSnippet(name: string, content: string, category: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const snippet: Snippet = {
|
||||
id: `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
name,
|
||||
content,
|
||||
category,
|
||||
is_default: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
return saveSnippet(snippet);
|
||||
}
|
||||
|
||||
async function updateSnippet(
|
||||
id: string,
|
||||
name: string,
|
||||
content: string,
|
||||
category: string
|
||||
): Promise<boolean> {
|
||||
const currentSnippets = await invoke<Snippet[]>("list_snippets");
|
||||
const existing = currentSnippets.find((s) => s.id === id);
|
||||
|
||||
if (!existing) {
|
||||
console.error("Snippet not found for update");
|
||||
return false;
|
||||
}
|
||||
|
||||
const updated: Snippet = {
|
||||
...existing,
|
||||
name,
|
||||
content,
|
||||
category,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return saveSnippet(updated);
|
||||
}
|
||||
|
||||
async function deleteSnippet(snippetId: string): Promise<boolean> {
|
||||
try {
|
||||
await invoke("delete_snippet", { snippetId });
|
||||
await loadSnippets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete snippet:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDefaults(): Promise<boolean> {
|
||||
try {
|
||||
await invoke("reset_default_snippets");
|
||||
await loadSnippets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to reset default snippets:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedCategory(category: string | null): void {
|
||||
selectedCategory.set(category);
|
||||
}
|
||||
|
||||
return {
|
||||
snippets: { subscribe: snippets.subscribe },
|
||||
categories: { subscribe: categories.subscribe },
|
||||
filteredSnippets: { subscribe: filteredSnippets.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
selectedCategory: { subscribe: selectedCategory.subscribe },
|
||||
loadSnippets,
|
||||
saveSnippet,
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
deleteSnippet,
|
||||
resetDefaults,
|
||||
setSelectedCategory,
|
||||
};
|
||||
}
|
||||
|
||||
export const snippetsStore = createSnippetsStore();
|
||||
@@ -104,10 +104,11 @@ export async function initStatsListener() {
|
||||
stats.set(newStats);
|
||||
});
|
||||
|
||||
// Load initial stats from backend
|
||||
// Load initial persisted stats from backend (no bridge required)
|
||||
try {
|
||||
const initialStats = await invoke<UsageStats>("get_usage_stats");
|
||||
const initialStats = await invoke<UsageStats>("get_persisted_stats");
|
||||
stats.set(initialStats);
|
||||
console.log("Loaded persisted stats:", initialStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial stats:", error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user