diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 9ce7001..60867d0 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -26,6 +26,11 @@ pub fn read_file(path: String) -> Result { fs::read_to_string(file_path).map_err(|e| format!("Failed to read file '{}': {}", path, e)) } +/// Tauri command: Reads a file, returning at most `max_bytes` from the end. +/// +/// For large files (e.g., multi-MB JSONL transcripts), this avoids sending +/// the full content through Tauri's JSON-based IPC which would freeze the UI. +/// /// Tauri command: Writes content to a file at the given path. /// Creates the file if it doesn't exist, overwrites if it does. /// Uses atomic write (write to temp then rename) to prevent data loss. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cb07370..37fc894 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,7 +12,7 @@ use commands::session::{load_session_state, save_session_state}; use std::path::PathBuf; use std::sync::Mutex; use tauri::menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder, SubmenuBuilder}; -use tauri::{Emitter, Manager, RunEvent}; +use tauri::{Emitter, Listener, Manager, RunEvent}; /// Supported markdown/text file extensions for file-open events. const SUPPORTED_EXTENSIONS: &[&str] = &["md", "markdown", "mdown", "mkd", "mdwn", "mdtxt", "txt"]; @@ -277,18 +277,28 @@ pub fn run() { .quit() .build()?; - let theme_light = CheckMenuItemBuilder::with_id("theme-light", "Light").build(app)?; - let theme_dark = CheckMenuItemBuilder::with_id("theme-dark", "Dark").build(app)?; + let theme_light = CheckMenuItemBuilder::with_id("theme-light", "Light") + .checked(false) + .build(app)?; + let theme_dark = CheckMenuItemBuilder::with_id("theme-dark", "Dark") + .checked(false) + .build(app)?; let theme_system = - CheckMenuItemBuilder::with_id("theme-system", "System (Auto)").build(app)?; + CheckMenuItemBuilder::with_id("theme-system", "System (Auto)") + .checked(false) + .build(app)?; let theme_solarized_light = CheckMenuItemBuilder::with_id("theme-solarized-light", "Solarized Light") + .checked(false) .build(app)?; let theme_solarized_dark = CheckMenuItemBuilder::with_id("theme-solarized-dark", "Solarized Dark") + .checked(false) .build(app)?; let theme_github = - CheckMenuItemBuilder::with_id("theme-github", "GitHub").build(app)?; + CheckMenuItemBuilder::with_id("theme-github", "GitHub") + .checked(false) + .build(app)?; let themes_menu = SubmenuBuilder::new(app, "Themes") .item(&theme_light) @@ -350,6 +360,29 @@ pub fn run() { app.set_menu(menu)?; + // Clone theme items for use in event handlers + let theme_items: Vec> = vec![ + theme_light.clone(), + theme_dark.clone(), + theme_system.clone(), + theme_solarized_light.clone(), + theme_solarized_dark.clone(), + theme_github.clone(), + ]; + let theme_items_sync = theme_items.clone(); + + // Listen for frontend theme sync (initial load + preferences UI changes) + app.listen("sync-theme-menu", move |event: tauri::Event| { + let payload = event.payload(); + // Payload arrives as JSON string e.g. "\"theme-system\"" + let selected_id = payload + .trim_matches('"') + .trim(); + for item in &theme_items_sync { + let _ = item.set_checked(item.id().0.as_str() == selected_id); + } + }); + app.on_menu_event(move |app_handle, event| { let id = event.id().0.as_str(); match id { @@ -454,6 +487,10 @@ pub fn run() { "system" => "auto", other => other, }; + // Update checkmarks: uncheck all, check selected + for item in &theme_items { + let _ = item.set_checked(item.id().0.as_str() == id); + } if let Some(window) = app_handle.get_webview_window("main") { let _ = window.emit("menu-set-theme", theme_value); } diff --git a/src/App.vue b/src/App.vue index 24bff67..f877072 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,8 +5,7 @@
- - +
@@ -41,11 +40,10 @@ - - diff --git a/src/components/transcript/TranscriptSummary.vue b/src/components/transcript/TranscriptSummary.vue deleted file mode 100644 index 7a20f50..0000000 --- a/src/components/transcript/TranscriptSummary.vue +++ /dev/null @@ -1,80 +0,0 @@ - - - - - diff --git a/src/components/transcript/TranscriptToolCall.vue b/src/components/transcript/TranscriptToolCall.vue deleted file mode 100644 index 7216bb0..0000000 --- a/src/components/transcript/TranscriptToolCall.vue +++ /dev/null @@ -1,145 +0,0 @@ - - - - - diff --git a/src/components/transcript/TranscriptViewer.vue b/src/components/transcript/TranscriptViewer.vue deleted file mode 100644 index d331cee..0000000 --- a/src/components/transcript/TranscriptViewer.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - - - diff --git a/src/stores/aiFiles.ts b/src/stores/aiFiles.ts index 2f61376..e7e8a8f 100644 --- a/src/stores/aiFiles.ts +++ b/src/stores/aiFiles.ts @@ -13,7 +13,7 @@ export interface FileInfo { export interface AiFile { name: string path: string - category: 'instruction' | 'session' | 'memory' + category: 'instruction' | 'memory' modifiedAt?: number } @@ -22,14 +22,10 @@ export const useAiFilesStore = defineStore('aiFiles', () => { const error = ref(null) const claudeProjectPath = ref(null) const instructions = ref([]) - const sessions = ref([]) const memoryFiles = ref([]) /** Whether any AI files were discovered */ - const hasAnyFiles = computed( - () => - instructions.value.length > 0 || sessions.value.length > 0 || memoryFiles.value.length > 0, - ) + const hasAnyFiles = computed(() => instructions.value.length > 0 || memoryFiles.value.length > 0) /** * Discover Claude-related files for a project root path. @@ -63,15 +59,6 @@ export const useAiFilesStore = defineStore('aiFiles', () => { dirPath: projectDir, }) - sessions.value = allFiles - .filter((f) => f.name.endsWith('.json') || f.name.endsWith('.jsonl')) - .map((f) => ({ - name: f.name, - path: f.path, - category: 'session' as const, - modifiedAt: f.modified_at, - })) - memoryFiles.value = allFiles .filter((f) => f.name.endsWith('.md')) .map((f) => ({ @@ -81,7 +68,6 @@ export const useAiFilesStore = defineStore('aiFiles', () => { modifiedAt: f.modified_at, })) } else { - sessions.value = [] memoryFiles.value = [] } } catch (e) { @@ -98,7 +84,6 @@ export const useAiFilesStore = defineStore('aiFiles', () => { error.value = null claudeProjectPath.value = null instructions.value = [] - sessions.value = [] memoryFiles.value = [] } @@ -108,7 +93,6 @@ export const useAiFilesStore = defineStore('aiFiles', () => { error, claudeProjectPath, instructions, - sessions, memoryFiles, // Computed diff --git a/src/stores/tabs.ts b/src/stores/tabs.ts index 9b7a10d..2fe397d 100644 --- a/src/stores/tabs.ts +++ b/src/stores/tabs.ts @@ -4,7 +4,6 @@ import { invoke } from '@tauri-apps/api/core' import type { Tab, EditorState } from '../types/tab' import { createDefaultEditorState } from '../types/tab' import { parseFrontMatter } from '../utils/frontmatter' -import { isTranscriptFile } from '../utils/transcriptParser' let nextUntitledNumber = 1 @@ -242,14 +241,6 @@ export const useTabsStore = defineStore('tabs', () => { try { const content = await invoke('read_file', { path: filePath }) - // Detect transcript files (.jsonl Claude Code sessions) - if (filePath.endsWith('.jsonl') && isTranscriptFile(content)) { - const tab = createTab(filePath, content) - tab.fileType = 'transcript' - - return tab - } - // Parse YAML front-matter: separate metadata from body content const { rawYaml, attributes, body, hasFrontMatter } = parseFrontMatter(content) diff --git a/src/stores/transcript.ts b/src/stores/transcript.ts deleted file mode 100644 index 4732892..0000000 --- a/src/stores/transcript.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { invoke } from '@tauri-apps/api/core' -import { parseTranscript, type ParsedTranscript } from '../utils/transcriptParser' - -export const useTranscriptStore = defineStore('transcript', () => { - const transcript = ref(null) - const loading = ref(false) - const error = ref(null) - const currentFilePath = ref(null) - - async function loadTranscript(filePath: string): Promise { - // Skip re-fetch if already loaded - if (currentFilePath.value === filePath) return - - loading.value = true - error.value = null - - try { - const content = await invoke('read_file', { path: filePath }) - transcript.value = parseTranscript(content) - currentFilePath.value = filePath - } catch (err) { - transcript.value = null - error.value = err instanceof Error ? err.message : 'Failed to load transcript' - } finally { - loading.value = false - } - } - - function clear(): void { - transcript.value = null - currentFilePath.value = null - error.value = null - loading.value = false - } - - return { - transcript, - loading, - error, - currentFilePath, - loadTranscript, - clear, - } -}) diff --git a/src/types/tab.ts b/src/types/tab.ts index 46c2e89..4a662e8 100644 --- a/src/types/tab.ts +++ b/src/types/tab.ts @@ -28,8 +28,6 @@ export interface Tab { isUntitled: boolean /** Persisted editor state for this tab */ editorState: EditorState - /** File type: markdown (default) or transcript (.jsonl Claude Code sessions) */ - fileType?: 'markdown' | 'transcript' } export function createDefaultEditorState(markdown: string = ''): EditorState { diff --git a/src/utils/transcriptParser.ts b/src/utils/transcriptParser.ts deleted file mode 100644 index e3168c4..0000000 --- a/src/utils/transcriptParser.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * Parser for Claude Code session transcript files (.jsonl). - * - * Converts JSONL transcript data into structured conversation messages - * with tool call pairing, file extraction, and summary statistics. - */ - -// ─── Public types ────────────────────────────────────────────────────── - -export interface ToolCall { - id: string - name: string - input: Record - result: string | null - isError: boolean -} - -export interface ConversationMessage { - id: string - role: 'human' | 'assistant' - textContent: string - toolCalls: ToolCall[] - timestamp: Date | null -} - -export interface TranscriptSummary { - messageCount: number - toolCallCount: number - filesReferenced: string[] - sessionId: string | null - duration: string | null -} - -export interface ParsedTranscript { - messages: ConversationMessage[] - summary: TranscriptSummary -} - -// ─── Internal types for raw JSON ─────────────────────────────────────── - -interface RawContentBlockText { - type: 'text' - text: string -} - -interface RawContentBlockThinking { - type: 'thinking' - thinking: string -} - -interface RawContentBlockToolUse { - type: 'tool_use' - id: string - name: string - input: Record -} - -interface RawContentBlockToolResult { - type: 'tool_result' - tool_use_id: string - content: string | RawContentBlockText[] - is_error?: boolean -} - -type RawContentBlock = - | RawContentBlockText - | RawContentBlockThinking - | RawContentBlockToolUse - | RawContentBlockToolResult - -interface RawLine { - type: string - message?: { - role: string - content: string | RawContentBlock[] - } - timestamp?: string - uuid?: string - sessionId?: string -} - -// Types to skip entirely -const SKIP_TYPES = new Set(['queue-operation', 'last-prompt', 'system', 'debug']) - -// Tool names that reference files -const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'Glob', 'Grep']) - -// ─── Type guards ─────────────────────────────────────────────────────── - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function isRawLine(value: unknown): value is RawLine { - return isRecord(value) && typeof value.type === 'string' -} - -function isContentBlockArray(content: unknown): content is RawContentBlock[] { - return Array.isArray(content) -} - -// ─── Public API ──────────────────────────────────────────────────────── - -/** - * Check whether raw file content looks like a Claude Code transcript. - * Examines the first non-empty line for JSON with a `type` field - * plus one of `uuid`, `timestamp`, or `sessionId`. - */ -export function isTranscriptFile(rawContent: string): boolean { - const firstLine = rawContent.split('\n').find((l) => l.trim().length > 0) - if (!firstLine) return false - - try { - const parsed: unknown = JSON.parse(firstLine.trim()) - if (!isRawLine(parsed)) return false - return ( - typeof parsed.uuid === 'string' || - typeof parsed.timestamp === 'string' || - typeof parsed.sessionId === 'string' - ) - } catch { - return false - } -} - -/** - * Parse a JSONL transcript into structured messages with tool call pairing. - */ -export function parseTranscript(jsonlContent: string): ParsedTranscript { - if (!jsonlContent.trim()) { - return emptyTranscript() - } - - const lines = jsonlContent.split('\n') - const parsedLines: RawLine[] = [] - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - try { - const obj: unknown = JSON.parse(trimmed) - if (isRawLine(obj)) { - parsedLines.push(obj) - } - } catch { - // Skip malformed lines - } - } - - // First pass: build messages and collect tool uses - const messages: ConversationMessage[] = [] - const toolCallMap = new Map() - const filesSet = new Set() - const timestamps: Date[] = [] - let sessionId: string | null = null - let messageCounter = 0 - - for (const raw of parsedLines) { - // Capture sessionId if present - if (raw.sessionId && !sessionId) { - sessionId = raw.sessionId - } - - // Skip infrastructure lines - if (SKIP_TYPES.has(raw.type)) continue - - // Collect timestamp - if (raw.timestamp) { - const ts = new Date(raw.timestamp) - if (!isNaN(ts.getTime())) { - timestamps.push(ts) - } - } - - if (!raw.message) continue - const content = raw.message.content - - if (raw.type === 'assistant') { - const msg = createAssistantMessage(String(++messageCounter), content, raw.timestamp) - // Register tool calls in the map for later pairing - for (const tc of msg.toolCalls) { - toolCallMap.set(tc.id, tc) - extractFileFromToolCall(tc, filesSet) - } - messages.push(msg) - } else if (raw.type === 'user') { - // Check if this is a tool-result-only turn - if (isContentBlockArray(content)) { - const hasToolResults = content.some((b) => isRecord(b) && b.type === 'tool_result') - const hasTextContent = content.some( - (b) => - isRecord(b) && - b.type === 'text' && - typeof b.text === 'string' && - (b.text as string).trim().length > 0, - ) - - if (hasToolResults) { - // Pair tool results with their tool_use - for (const block of content) { - if (isRecord(block) && block.type === 'tool_result') { - const toolUseId = block.tool_use_id as string - const tc = toolCallMap.get(toolUseId) - if (tc) { - tc.result = extractToolResultContent( - block.content as string | RawContentBlockText[], - ) - tc.isError = block.is_error === true - } - } - } - - // If the turn ONLY has tool results (no text), skip creating a human message - if (!hasTextContent) continue - } - } - - // Create a human message - const textContent = extractTextFromContent(content) - if (textContent !== null) { - messages.push({ - id: String(++messageCounter), - role: 'human', - textContent, - toolCalls: [], - timestamp: raw.timestamp ? parseTimestamp(raw.timestamp) : null, - }) - } - } - } - - // Compute summary - let toolCallCount = 0 - for (const msg of messages) { - toolCallCount += msg.toolCalls.length - } - - const duration = computeDuration(timestamps) - - return { - messages, - summary: { - messageCount: messages.length, - toolCallCount, - filesReferenced: Array.from(filesSet), - sessionId, - duration, - }, - } -} - -// ─── Internal helpers ────────────────────────────────────────────────── - -function emptyTranscript(): ParsedTranscript { - return { - messages: [], - summary: { - messageCount: 0, - toolCallCount: 0, - filesReferenced: [], - sessionId: null, - duration: null, - }, - } -} - -function createAssistantMessage( - id: string, - content: string | RawContentBlock[], - timestamp?: string, -): ConversationMessage { - const toolCalls: ToolCall[] = [] - const textParts: string[] = [] - - if (isContentBlockArray(content)) { - for (const block of content) { - if (!isRecord(block)) continue - if (block.type === 'text' && typeof block.text === 'string') { - textParts.push(block.text as string) - } else if (block.type === 'tool_use') { - toolCalls.push({ - id: block.id as string, - name: block.name as string, - input: (block.input as Record) ?? {}, - result: null, - isError: false, - }) - } - // Skip 'thinking' blocks - } - } else if (typeof content === 'string') { - textParts.push(content) - } - - return { - id, - role: 'assistant', - textContent: textParts.join('\n'), - toolCalls, - timestamp: timestamp ? parseTimestamp(timestamp) : null, - } -} - -function extractTextFromContent(content: string | RawContentBlock[]): string | null { - if (typeof content === 'string') return content - - if (isContentBlockArray(content)) { - const textParts: string[] = [] - for (const block of content) { - if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') { - textParts.push(block.text as string) - } - } - return textParts.length > 0 ? textParts.join('\n') : null - } - - return null -} - -function extractToolResultContent(content: string | RawContentBlockText[]): string { - if (typeof content === 'string') return content - - if (Array.isArray(content)) { - return content - .filter((b) => isRecord(b) && b.type === 'text' && typeof b.text === 'string') - .map((b) => (b as RawContentBlockText).text) - .join('\n') - } - - return String(content) -} - -function extractFileFromToolCall(tc: ToolCall, filesSet: Set): void { - if (!FILE_TOOLS.has(tc.name)) return - const filePath = (tc.input.file_path ?? tc.input.path) as string | undefined - if (typeof filePath === 'string' && filePath.length > 0) { - filesSet.add(filePath) - } -} - -function parseTimestamp(ts: string): Date | null { - const d = new Date(ts) - return isNaN(d.getTime()) ? null : d -} - -function computeDuration(timestamps: Date[]): string | null { - if (timestamps.length < 2) return null - const sorted = timestamps.sort((a, b) => a.getTime() - b.getTime()) - const diffMs = sorted[sorted.length - 1]!.getTime() - sorted[0]!.getTime() - const totalSeconds = Math.floor(diffMs / 1000) - - if (totalSeconds < 60) return `${totalSeconds}s` - - const minutes = Math.floor(totalSeconds / 60) - const seconds = totalSeconds % 60 - - if (seconds === 0) return `${minutes}m` - return `${minutes}m ${seconds}s` -}