From 2d2cdf3b19fdcee4a033275b02853860a94737ca Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sat, 10 Oct 2026 04:19:34 -0300 Subject: [PATCH] refactor: reduce cyclomatic complexity of settings, tool data and layout sanitizers Add characterization tests first, then split the hot spots into small helpers: - STT/TTS settings updates move to extensions/piagentui-speech-settings.ts (rule tables) - defaultExtractData is split into diff/diagnostic/output extractors - getToolSummaryCategory becomes an ordered rule table (toolSummary.ts) - sanitizePersistedTerminalLayoutMap is split per level - duplicated diff stats helpers are merged into utils/diffStats.ts --- extensions/piagentui-server.ts | 111 +------- extensions/piagentui-speech-settings.test.ts | 153 ++++++++++ extensions/piagentui-speech-settings.ts | 126 +++++++++ src/features/message/MessageRenderer.tsx | 96 +------ src/features/message/parts/ToolPartView.tsx | 45 +-- src/features/message/toolSummary.test.ts | 54 ++++ src/features/message/toolSummary.ts | 47 +++ src/features/message/tools/registry.test.ts | 139 ++++++++- src/features/message/tools/registry.tsx | 283 +++++++++++-------- src/store/layoutStore.sanitize.test.ts | 96 +++++++ src/store/layoutStore.ts | 110 +++---- src/utils/diffStats.test.ts | 56 ++++ src/utils/diffStats.ts | 50 ++++ 13 files changed, 962 insertions(+), 404 deletions(-) create mode 100644 extensions/piagentui-speech-settings.test.ts create mode 100644 extensions/piagentui-speech-settings.ts create mode 100644 src/features/message/toolSummary.test.ts create mode 100644 src/features/message/toolSummary.ts create mode 100644 src/store/layoutStore.sanitize.test.ts create mode 100644 src/utils/diffStats.test.ts create mode 100644 src/utils/diffStats.ts diff --git a/extensions/piagentui-server.ts b/extensions/piagentui-server.ts index 1035d3a..5ef3c5c 100644 --- a/extensions/piagentui-server.ts +++ b/extensions/piagentui-server.ts @@ -21,6 +21,7 @@ import { type StoredSttSettings, type StoredTtsSettings, } from './piagentui-settings-store.js' +import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js' import { buildExtensionCatalog, executeExtensionInstallCommand, @@ -774,56 +775,11 @@ export default function (pi: ExtensionAPI) { function updateSttSettings(update: any): { ok: true; settings: SanitizedSttSettings } | { ok: false; error: string } { const current = getLocalSettings() - const existing = current.stt || {} - const next: StoredSttSettings = { ...existing } + const result = applySttSettingsUpdate(current.stt || {}, update) + if (!result.ok) return result - if (update.enabled !== undefined) next.enabled = update.enabled === true - if (update.providerKind !== undefined) { - if (update.providerKind !== 'openai' && update.providerKind !== 'openai-compatible') { - return { ok: false, error: 'Invalid providerKind' } - } - next.providerKind = update.providerKind - } - if (update.mode !== undefined) { - if (update.mode !== 'file' && update.mode !== 'realtime') return { ok: false, error: 'Invalid mode' } - next.mode = update.mode - } - if (update.baseUrl !== undefined) { - if (typeof update.baseUrl !== 'string' || !validateSttBaseUrl(update.baseUrl)) { - return { ok: false, error: 'Invalid baseUrl' } - } - next.baseUrl = update.baseUrl.replace(/\/+$/, '') - } - if (update.apiKeyRequired !== undefined) next.apiKeyRequired = update.apiKeyRequired === true - if (update.transcriptionEndpoint !== undefined) { - if (typeof update.transcriptionEndpoint !== 'string' || !update.transcriptionEndpoint.startsWith('/')) { - return { ok: false, error: 'Invalid transcriptionEndpoint' } - } - next.transcriptionEndpoint = update.transcriptionEndpoint - } - if (update.transcriptionModel !== undefined) { - if (typeof update.transcriptionModel !== 'string' || !update.transcriptionModel.trim()) { - return { ok: false, error: 'Invalid transcriptionModel' } - } - next.transcriptionModel = update.transcriptionModel.trim() - } - if (update.language !== undefined) { - if (update.language === null || update.language === '') delete next.language - else if (typeof update.language === 'string') next.language = update.language.trim() - else return { ok: false, error: 'Invalid language' } - } - if (update.insertMode !== undefined) { - if (update.insertMode !== 'append' && update.insertMode !== 'replace') - return { ok: false, error: 'Invalid insertMode' } - next.insertMode = update.insertMode - } - if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) { - if (update.apiKey === null) delete next.apiKey - else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim() - } - - saveLocalSettings({ ...current, stt: next }) - return { ok: true, settings: sanitizeSttSettings(next) } + saveLocalSettings({ ...current, stt: result.next }) + return { ok: true, settings: sanitizeSttSettings(result.next) } } function getEffectiveTtsSettings(): StoredTtsSettings { @@ -857,62 +813,13 @@ export default function (pi: ExtensionAPI) { } } - function isTtsResponseFormat(value: unknown): value is NonNullable { - return ( - value === 'mp3' || value === 'opus' || value === 'aac' || value === 'flac' || value === 'wav' || value === 'pcm' - ) - } - function updateTtsSettings(update: any): { ok: true; settings: SanitizedTtsSettings } | { ok: false; error: string } { const current = getLocalSettings() - const existing = current.tts || {} - const next: StoredTtsSettings = { ...existing } + const result = applyTtsSettingsUpdate(current.tts || {}, update) + if (!result.ok) return result - if (update.enabled !== undefined) next.enabled = update.enabled === true - if (update.providerKind !== undefined) { - if (update.providerKind !== 'openai' && update.providerKind !== 'openai-compatible') { - return { ok: false, error: 'Invalid providerKind' } - } - next.providerKind = update.providerKind - } - if (update.baseUrl !== undefined) { - if (typeof update.baseUrl !== 'string' || !validateSttBaseUrl(update.baseUrl)) { - return { ok: false, error: 'Invalid baseUrl' } - } - next.baseUrl = update.baseUrl.replace(/\/+$/, '') - } - if (update.apiKeyRequired !== undefined) next.apiKeyRequired = update.apiKeyRequired === true - if (update.speechEndpoint !== undefined) { - if (typeof update.speechEndpoint !== 'string' || !update.speechEndpoint.startsWith('/')) { - return { ok: false, error: 'Invalid speechEndpoint' } - } - next.speechEndpoint = update.speechEndpoint - } - if (update.model !== undefined) { - if (typeof update.model !== 'string' || !update.model.trim()) return { ok: false, error: 'Invalid model' } - next.model = update.model.trim() - } - if (update.voice !== undefined) { - if (typeof update.voice !== 'string' || !update.voice.trim()) return { ok: false, error: 'Invalid voice' } - next.voice = update.voice.trim() - } - if (update.responseFormat !== undefined) { - if (!isTtsResponseFormat(update.responseFormat)) return { ok: false, error: 'Invalid responseFormat' } - next.responseFormat = update.responseFormat - } - if (update.speed !== undefined) { - if (typeof update.speed !== 'number' || update.speed < 0.25 || update.speed > 4) { - return { ok: false, error: 'Invalid speed' } - } - next.speed = update.speed - } - if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) { - if (update.apiKey === null) delete next.apiKey - else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim() - } - - saveLocalSettings({ ...current, tts: next }) - return { ok: true, settings: sanitizeTtsSettings(next) } + saveLocalSettings({ ...current, tts: result.next }) + return { ok: true, settings: sanitizeTtsSettings(result.next) } } async function readRequestBuffer(req: http.IncomingMessage): Promise { diff --git a/extensions/piagentui-speech-settings.test.ts b/extensions/piagentui-speech-settings.test.ts new file mode 100644 index 0000000..1d495a9 --- /dev/null +++ b/extensions/piagentui-speech-settings.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { applySttSettingsUpdate, applyTtsSettingsUpdate, isTtsResponseFormat } from './piagentui-speech-settings.js' + +function stt(update: Record, existing = {}) { + return applySttSettingsUpdate(existing, update) +} + +function tts(update: Record, existing = {}) { + return applyTtsSettingsUpdate(existing, update) +} + +describe('applySttSettingsUpdate', () => { + it('returns the existing settings untouched for an empty update', () => { + const existing = { enabled: true, language: 'pt' } + expect(stt({}, existing)).toEqual({ ok: true, next: existing }) + }) + + it('applies a valid update of every field', () => { + const result = stt({ + enabled: true, + providerKind: 'openai', + mode: 'realtime', + baseUrl: 'https://api.example.com/v1///', + apiKeyRequired: true, + transcriptionEndpoint: '/audio/transcriptions', + transcriptionModel: ' whisper-1 ', + language: ' en ', + insertMode: 'replace', + apiKey: ' secret ', + }) + expect(result).toEqual({ + ok: true, + next: { + enabled: true, + providerKind: 'openai', + mode: 'realtime', + baseUrl: 'https://api.example.com/v1', + apiKeyRequired: true, + transcriptionEndpoint: '/audio/transcriptions', + transcriptionModel: 'whisper-1', + language: 'en', + insertMode: 'replace', + apiKey: 'secret', + }, + }) + }) + + it('does not mutate the existing settings', () => { + const existing = { enabled: false } + stt({ enabled: true }, existing) + expect(existing).toEqual({ enabled: false }) + }) + + it.each([ + ['providerKind', { providerKind: 'other' }], + ['mode', { mode: 'stream' }], + ['baseUrl', { baseUrl: 'ftp://host' }], + ['baseUrl', { baseUrl: 42 }], + ['transcriptionEndpoint', { transcriptionEndpoint: 'audio' }], + ['transcriptionModel', { transcriptionModel: ' ' }], + ['language', { language: 5 }], + ['insertMode', { insertMode: 'prepend' }], + ])('rejects an invalid %s', (field, update) => { + expect(stt(update)).toEqual({ ok: false, error: `Invalid ${field}` }) + }) + + it('coerces enabled/apiKeyRequired to booleans', () => { + expect(stt({ enabled: 'yes', apiKeyRequired: 1 })).toEqual({ + ok: true, + next: { enabled: false, apiKeyRequired: false }, + }) + }) + + it('clears language with null or empty string', () => { + expect(stt({ language: null }, { language: 'pt' })).toEqual({ ok: true, next: {} }) + expect(stt({ language: '' }, { language: 'pt' })).toEqual({ ok: true, next: {} }) + }) + + it('handles apiKey: null clears, blank string keeps, non-string keeps', () => { + expect(stt({ apiKey: null }, { apiKey: 'old' })).toEqual({ ok: true, next: {} }) + expect(stt({ apiKey: ' ' }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } }) + expect(stt({ apiKey: 12 }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } }) + }) +}) + +describe('applyTtsSettingsUpdate', () => { + it('returns the existing settings untouched for an empty update', () => { + const existing = { voice: 'alloy' } + expect(tts({}, existing)).toEqual({ ok: true, next: existing }) + }) + + it('applies a valid update of every field', () => { + const result = tts({ + enabled: true, + providerKind: 'openai-compatible', + baseUrl: 'http://localhost:8880/v1/', + apiKeyRequired: true, + speechEndpoint: '/audio/speech', + model: ' tts-1-hd ', + voice: ' nova ', + responseFormat: 'wav', + speed: 1.5, + apiKey: ' k ', + }) + expect(result).toEqual({ + ok: true, + next: { + enabled: true, + providerKind: 'openai-compatible', + baseUrl: 'http://localhost:8880/v1', + apiKeyRequired: true, + speechEndpoint: '/audio/speech', + model: 'tts-1-hd', + voice: 'nova', + responseFormat: 'wav', + speed: 1.5, + apiKey: 'k', + }, + }) + }) + + it.each([ + ['providerKind', { providerKind: 'x' }], + ['baseUrl', { baseUrl: 'not a url' }], + ['speechEndpoint', { speechEndpoint: 'speech' }], + ['model', { model: '' }], + ['voice', { voice: 3 }], + ['responseFormat', { responseFormat: 'ogg' }], + ['speed', { speed: '1' }], + ['speed', { speed: 0.1 }], + ['speed', { speed: 4.5 }], + ])('rejects an invalid %s', (field, update) => { + expect(tts(update)).toEqual({ ok: false, error: `Invalid ${field}` }) + }) + + it('accepts speed at both bounds', () => { + expect(tts({ speed: 0.25 })).toEqual({ ok: true, next: { speed: 0.25 } }) + expect(tts({ speed: 4 })).toEqual({ ok: true, next: { speed: 4 } }) + }) + + it('handles apiKey: null clears, blank string keeps', () => { + expect(tts({ apiKey: null }, { apiKey: 'old' })).toEqual({ ok: true, next: {} }) + expect(tts({ apiKey: ' ' }, { apiKey: 'old' })).toEqual({ ok: true, next: { apiKey: 'old' } }) + }) +}) + +describe('isTtsResponseFormat', () => { + it('accepts supported formats only', () => { + for (const f of ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']) expect(isTtsResponseFormat(f)).toBe(true) + expect(isTtsResponseFormat('ogg')).toBe(false) + expect(isTtsResponseFormat(undefined)).toBe(false) + }) +}) diff --git a/extensions/piagentui-speech-settings.ts b/extensions/piagentui-speech-settings.ts new file mode 100644 index 0000000..e6a414c --- /dev/null +++ b/extensions/piagentui-speech-settings.ts @@ -0,0 +1,126 @@ +import { validateSttBaseUrl } from './piagentui-server-core.js' +import type { StoredSttSettings, StoredTtsSettings } from './piagentui-settings-store.js' + +export type SettingsUpdateResult = { ok: true; next: T } | { ok: false; error: string } + +type Fields = Record +/** Assigns a validated value to `next`; returns false when the value is invalid. */ +type FieldRule = (value: unknown, next: Fields) => boolean + +const TTS_RESPONSE_FORMATS = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'] as const + +export function isTtsResponseFormat(value: unknown): value is NonNullable { + return typeof value === 'string' && (TTS_RESPONSE_FORMATS as readonly string[]).includes(value) +} + +const booleanField = + (key: string): FieldRule => + (value, next) => { + next[key] = value === true + return true + } + +const oneOfField = + (key: string, allowed: readonly string[]): FieldRule => + (value, next) => { + if (typeof value !== 'string' || !allowed.includes(value)) return false + next[key] = value + return true + } + +const endpointField = + (key: string): FieldRule => + (value, next) => { + if (typeof value !== 'string' || !value.startsWith('/')) return false + next[key] = value + return true + } + +const trimmedField = + (key: string): FieldRule => + (value, next) => { + if (typeof value !== 'string' || !value.trim()) return false + next[key] = value.trim() + return true + } + +const baseUrlField: FieldRule = (value, next) => { + if (typeof value !== 'string' || !validateSttBaseUrl(value)) return false + next.baseUrl = value.replace(/\/+$/, '') + return true +} + +const languageField: FieldRule = (value, next) => { + if (value === null || value === '') delete next.language + else if (typeof value === 'string') next.language = value.trim() + else return false + return true +} + +const speedField: FieldRule = (value, next) => { + if (typeof value !== 'number' || value < 0.25 || value > 4) return false + next.speed = value + return true +} + +const PROVIDER_KINDS = ['openai', 'openai-compatible'] as const + +const STT_RULES: Array<[string, FieldRule]> = [ + ['enabled', booleanField('enabled')], + ['providerKind', oneOfField('providerKind', PROVIDER_KINDS)], + ['mode', oneOfField('mode', ['file', 'realtime'])], + ['baseUrl', baseUrlField], + ['apiKeyRequired', booleanField('apiKeyRequired')], + ['transcriptionEndpoint', endpointField('transcriptionEndpoint')], + ['transcriptionModel', trimmedField('transcriptionModel')], + ['language', languageField], + ['insertMode', oneOfField('insertMode', ['append', 'replace'])], +] + +const TTS_RULES: Array<[string, FieldRule]> = [ + ['enabled', booleanField('enabled')], + ['providerKind', oneOfField('providerKind', PROVIDER_KINDS)], + ['baseUrl', baseUrlField], + ['apiKeyRequired', booleanField('apiKeyRequired')], + ['speechEndpoint', endpointField('speechEndpoint')], + ['model', trimmedField('model')], + ['voice', trimmedField('voice')], + ['responseFormat', oneOfField('responseFormat', TTS_RESPONSE_FORMATS)], + ['speed', speedField], +] + +function applyUpdate( + existing: T, + update: Record, + rules: Array<[string, FieldRule]>, +): SettingsUpdateResult { + const next: Fields = { ...(existing as Fields) } + + for (const [field, rule] of rules) { + if (update[field] === undefined) continue + if (!rule(update[field], next)) return { ok: false, error: `Invalid ${field}` } + } + + if (Object.prototype.hasOwnProperty.call(update, 'apiKey')) { + if (update.apiKey === null) delete next.apiKey + else if (typeof update.apiKey === 'string' && update.apiKey.trim()) next.apiKey = update.apiKey.trim() + } + + return { ok: true, next: next as T } +} + +/** Validates a PATCH-style STT update against the stored settings. Pure: does not persist anything. */ +export function applySttSettingsUpdate( + existing: StoredSttSettings, + update: Record, +): SettingsUpdateResult { + return applyUpdate(existing, update, STT_RULES) +} + +/** Validates a PATCH-style TTS update against the stored settings. Pure: does not persist anything. */ +export function applyTtsSettingsUpdate( + existing: StoredTtsSettings, + update: Record, +): SettingsUpdateResult { + return applyUpdate(existing, update, TTS_RULES) +} diff --git a/src/features/message/MessageRenderer.tsx b/src/features/message/MessageRenderer.tsx index d464805..f3b2229 100644 --- a/src/features/message/MessageRenderer.tsx +++ b/src/features/message/MessageRenderer.tsx @@ -1,6 +1,5 @@ import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { diffLines } from 'diff' import { animate } from 'motion/mini' import { ChevronDownIcon, ChevronRightIcon, SplitIcon, SpinnerIcon, UndoIcon } from '../../components/Icons' import { CopyButton, SmoothHeight } from '../../components/ui' @@ -25,6 +24,8 @@ import { MessageErrorView, } from './parts' import { extractToolData } from './tools' +import { getToolSummaryCategory, type ToolSummaryCategory } from './toolSummary' +import { computeDiffStats } from '../../utils/diffStats' import type { Message, Part, @@ -597,7 +598,7 @@ const ToolGroup = memo(function ToolGroup({ for (const part of parts) { if (part.state?.status === 'error') continue const data = extractToolData(part) - const stats = data.diffStats || computePartDiffStats(data) + const stats = data.diffStats || computeDiffStats(data) if (stats) { additions += stats.additions deletions += stats.deletions @@ -774,21 +775,6 @@ function formatTokens( return `${total} ${t('tokens')}` } -type ToolSummaryCategory = - | 'execute' - | 'write' - | 'edit' - | 'read' - | 'search' - | 'list' - | 'network' - | 'task' - | 'todo' - | 'question' - | 'skill' - | 'think' - | 'other' - type ToolSummaryPhase = 'done' | 'active' | 'failed' interface SummarySegment { @@ -897,45 +883,6 @@ function formatToolSummarySegment( return t(key, { count }) } -function getToolSummaryCategory(toolName: string): ToolSummaryCategory { - const lower = toolName.toLowerCase() - - if (lower.includes('todo')) return 'todo' - if (lower === 'task') return 'task' - if (lower.includes('question') || lower.includes('ask')) return 'question' - if (lower.includes('skill')) return 'skill' - if ( - lower.includes('bash') || - lower === 'sh' || - lower.includes('cmd') || - lower.includes('terminal') || - lower.includes('shell') - ) { - return 'execute' - } - if (lower.includes('write') || lower.includes('save')) { - return 'write' - } - if (lower.includes('edit') || lower.includes('replace') || lower.includes('patch')) { - return 'edit' - } - if ( - lower.includes('web') || - lower.includes('fetch') || - lower.includes('http') || - lower.includes('browse') || - lower.includes('network') || - lower.includes('exa') - ) { - return 'network' - } - if (lower.includes('read') || lower.includes('cat')) return 'read' - if (lower.includes('grep') || lower.includes('search')) return 'search' - if (lower.includes('glob') || lower.includes('find')) return 'list' - if (lower.includes('think') || lower.includes('reason') || lower.includes('plan')) return 'think' - return 'other' -} - function isToolPartActive(part: ToolPart): boolean { return part.state?.status === 'running' || part.state?.status === 'pending' } @@ -946,43 +893,6 @@ function getTaskChildSessionId(part: ToolPart): string | undefined { return metadata?.sessionId as string | undefined } -/** 从 extractToolData 的结果计算 diff stats(当 metadata 没给 diffStats 时) */ -function computePartDiffStats(data: { - diff?: { before: string; after: string } | string - files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }> -}): { additions: number; deletions: number } | undefined { - if (data.files?.length) { - let a = 0, - d = 0 - for (const f of data.files) { - if (f.additions !== undefined) a += f.additions - if (f.deletions !== undefined) d += f.deletions - if (f.additions === undefined && f.before !== undefined && f.after !== undefined) { - const s = diffPairStats(f.before, f.after) - a += s.additions - d += s.deletions - } - } - return a || d ? { additions: a, deletions: d } : undefined - } - if (data.diff && typeof data.diff === 'object') { - const s = diffPairStats(data.diff.before, data.diff.after) - return s.additions || s.deletions ? s : undefined - } - return undefined -} - -function diffPairStats(before: string, after: string): { additions: number; deletions: number } { - const changes = diffLines(before, after) - let additions = 0, - deletions = 0 - for (const c of changes) { - if (c.added) additions += c.count || 0 - if (c.removed) deletions += c.count || 0 - } - return { additions, deletions } -} - // ============================================ // Helper: Group parts for rendering // ============================================ diff --git a/src/features/message/parts/ToolPartView.tsx b/src/features/message/parts/ToolPartView.tsx index 694a044..e43c93a 100644 --- a/src/features/message/parts/ToolPartView.tsx +++ b/src/features/message/parts/ToolPartView.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { diffLines } from 'diff' +import { computeDiffStats } from '../../../utils/diffStats' import { ChevronDownIcon, ChevronRightIcon } from '../../../components/Icons' import type { ToolPart } from '../../../types/message' import { useDelayedRender } from '../../../hooks' @@ -203,7 +203,7 @@ export const ToolPartView = memo(function ToolPartView({ if (descriptive) { const hasDiffFiles = !!toolData.files?.length // diffStats 可能从 metadata 来,也可能需要从 diff 数据计算 - const diffStats = toolData.diffStats || computeDiffStatsFromData(toolData) + const diffStats = toolData.diffStats || computeDiffStats(toolData) return (
@@ -451,47 +451,6 @@ function isReadableTool(toolName: string): boolean { return READABLE_TOOL_PATTERNS.test(toolName.toLowerCase()) } -/** 从 diff 数据计算 diffStats(当 metadata 没给 diffStats 时用) */ -function computeDiffStatsFromData(data: { - diff?: { before: string; after: string } | string - files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }> -}): { additions: number; deletions: number } | undefined { - // 多文件 - if (data.files?.length) { - let additions = 0, - deletions = 0 - for (const f of data.files) { - if (f.additions !== undefined) additions += f.additions - if (f.deletions !== undefined) deletions += f.deletions - if (f.additions === undefined && f.before !== undefined && f.after !== undefined) { - const s = computeDiffPair(f.before, f.after) - additions += s.additions - deletions += s.deletions - } - } - return additions || deletions ? { additions, deletions } : undefined - } - - // 单个 diff - if (data.diff && typeof data.diff === 'object') { - const s = computeDiffPair(data.diff.before, data.diff.after) - return s.additions || s.deletions ? s : undefined - } - - return undefined -} - -function computeDiffPair(before: string, after: string): { additions: number; deletions: number } { - const changes = diffLines(before, after) - let additions = 0, - deletions = 0 - for (const c of changes) { - if (c.added) additions += c.count || 0 - if (c.removed) deletions += c.count || 0 - } - return { additions, deletions } -} - // ============================================ // ToolBody - 根据工具类型选择渲染器 // ============================================ diff --git a/src/features/message/toolSummary.test.ts b/src/features/message/toolSummary.test.ts new file mode 100644 index 0000000..9085477 --- /dev/null +++ b/src/features/message/toolSummary.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { getToolSummaryCategory } from './toolSummary' + +describe('getToolSummaryCategory', () => { + it.each([ + ['TodoWrite', 'todo'], + ['task', 'task'], + ['AskUserQuestion', 'question'], + ['skill', 'skill'], + ['bash', 'execute'], + ['sh', 'execute'], + ['run_cmd', 'execute'], + ['terminal', 'execute'], + ['shell_exec', 'execute'], + ['write', 'write'], + ['save_file', 'write'], + ['edit', 'edit'], + ['str_replace', 'edit'], + ['apply_patch', 'edit'], + ['webfetch', 'network'], + ['http_get', 'network'], + ['browse', 'network'], + ['exa_search', 'network'], + ['read', 'read'], + ['cat', 'read'], + ['grep', 'search'], + ['search', 'search'], + ['glob', 'list'], + ['find_files', 'list'], + ['think', 'think'], + ['plan_mode', 'think'], + ['unknown_tool', 'other'], + ])('maps %s to %s', (tool, category) => { + expect(getToolSummaryCategory(tool)).toBe(category) + }) + + it('is case insensitive', () => { + expect(getToolSummaryCategory('BASH')).toBe('execute') + }) + + it('matches "task" and "sh" only exactly', () => { + expect(getToolSummaryCategory('tasks')).toBe('question') // contains "ask", not an exact "task" + expect(getToolSummaryCategory('push')).toBe('other') + }) + + it('keeps the precedence of earlier rules', () => { + // "todo" beats "write"; "ask" beats "read" in "ask_read"; "shell" (execute) beats "cat" in "shell_cat" + expect(getToolSummaryCategory('todowrite')).toBe('todo') + expect(getToolSummaryCategory('ask_read')).toBe('question') + expect(getToolSummaryCategory('shell_cat')).toBe('execute') + // "web" (network) beats "search" + expect(getToolSummaryCategory('websearch')).toBe('network') + }) +}) diff --git a/src/features/message/toolSummary.ts b/src/features/message/toolSummary.ts new file mode 100644 index 0000000..e3aa6cb --- /dev/null +++ b/src/features/message/toolSummary.ts @@ -0,0 +1,47 @@ +export type ToolSummaryCategory = + | 'execute' + | 'write' + | 'edit' + | 'read' + | 'search' + | 'list' + | 'network' + | 'task' + | 'todo' + | 'question' + | 'skill' + | 'think' + | 'other' + +interface CategoryRule { + category: ToolSummaryCategory + /** Exact (lowercase) tool names. */ + exact?: readonly string[] + /** Substrings looked up in the lowercase tool name. */ + includes?: readonly string[] +} + +/** Order matters: the first matching rule wins (e.g. "todowrite" is a todo, not a write). */ +const CATEGORY_RULES: readonly CategoryRule[] = [ + { category: 'todo', includes: ['todo'] }, + { category: 'task', exact: ['task'] }, + { category: 'question', includes: ['question', 'ask'] }, + { category: 'skill', includes: ['skill'] }, + { category: 'execute', exact: ['sh'], includes: ['bash', 'cmd', 'terminal', 'shell'] }, + { category: 'write', includes: ['write', 'save'] }, + { category: 'edit', includes: ['edit', 'replace', 'patch'] }, + { category: 'network', includes: ['web', 'fetch', 'http', 'browse', 'network', 'exa'] }, + { category: 'read', includes: ['read', 'cat'] }, + { category: 'search', includes: ['grep', 'search'] }, + { category: 'list', includes: ['glob', 'find'] }, + { category: 'think', includes: ['think', 'reason', 'plan'] }, +] + +function matchesRule(lowerName: string, rule: CategoryRule): boolean { + return !!rule.exact?.includes(lowerName) || !!rule.includes?.some(keyword => lowerName.includes(keyword)) +} + +export function getToolSummaryCategory(toolName: string): ToolSummaryCategory { + const lower = toolName.toLowerCase() + return CATEGORY_RULES.find(rule => matchesRule(lower, rule))?.category ?? 'other' +} diff --git a/src/features/message/tools/registry.test.ts b/src/features/message/tools/registry.test.ts index 68363a9..5fd1187 100644 --- a/src/features/message/tools/registry.test.ts +++ b/src/features/message/tools/registry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { ToolPart } from '../../../types/message' -import { defaultExtractData } from './registry' +import { defaultExtractData, extractToolData } from './registry' describe('defaultExtractData', () => { it('extracts files and diagnostics from metadata', () => { @@ -43,3 +43,140 @@ describe('defaultExtractData', () => { ]) }) }) + +function makePart(state: Record | undefined): ToolPart { + return { + type: 'tool', + tool: 'custom', + id: 'tool-1', + callID: 'call-1', + sessionID: 'session-1', + messageID: 'message-1', + state, + } as unknown as ToolPart +} + +describe('defaultExtractData branches', () => { + it('returns an empty result for a part without state', () => { + expect(defaultExtractData(makePart(undefined))).toEqual({}) + }) + + it('serializes input as JSON only when it has keys', () => { + expect(defaultExtractData(makePart({ input: { a: 1 } }))).toMatchObject({ + input: JSON.stringify({ a: 1 }, null, 2), + inputLang: 'json', + }) + expect(defaultExtractData(makePart({ input: {} })).input).toBeUndefined() + }) + + it('extracts the error as a string', () => { + expect(defaultExtractData(makePart({ error: 'boom' })).error).toBe('boom') + }) + + it('prefers metadata.filepath over input.filePath and detects the output language', () => { + const fromMetadata = defaultExtractData( + makePart({ input: { filePath: 'b.py' }, metadata: { filepath: 'a.ts' }, output: 'x' }), + ) + expect(fromMetadata.filePath).toBe('a.ts') + expect(fromMetadata.outputLang).toBeTruthy() + expect(defaultExtractData(makePart({ input: { filePath: 'b.py' } })).filePath).toBe('b.py') + }) + + it('extracts a numeric exit code only', () => { + expect(defaultExtractData(makePart({ metadata: { exit: 2 } })).exitCode).toBe(2) + expect(defaultExtractData(makePart({ metadata: { exit: '2' } })).exitCode).toBeUndefined() + }) + + it('maps metadata.files with the unknown fallback for the name', () => { + const data = defaultExtractData(makePart({ metadata: { files: [{ file: 'f.ts' }, {}] } })) + expect(data.files?.map(f => f.filePath)).toEqual(['f.ts', 'unknown']) + }) + + it('uses a unified metadata.diff with filediff stats', () => { + const data = defaultExtractData( + makePart({ metadata: { diff: '@@', filediff: { additions: 3 } }, output: 'ignored' }), + ) + expect(data.diff).toBe('@@') + expect(data.diffStats).toEqual({ additions: 3, deletions: 0 }) + expect(data.output).toBeUndefined() + }) + + it('keeps a unified metadata.diff without stats when filediff has no counters', () => { + const data = defaultExtractData(makePart({ metadata: { diff: '@@', filediff: {} } })) + expect(data.diffStats).toBeUndefined() + }) + + it('reads filediff.patch, falling back to before/after', () => { + const patch = defaultExtractData(makePart({ metadata: { filediff: { patch: 'P', additions: 1, deletions: 2 } } })) + expect(patch.diff).toBe('P') + expect(patch.diffStats).toEqual({ additions: 1, deletions: 2 }) + + const pair = defaultExtractData(makePart({ metadata: { filediff: { before: 'a', after: 'b' } } })) + expect(pair.diff).toEqual({ before: 'a', after: 'b' }) + expect(pair.diffStats).toBeUndefined() + + expect(defaultExtractData(makePart({ metadata: { filediff: { before: 'a' } } })).diff).toBeUndefined() + }) + + it('keeps only error and warning diagnostics and skips malformed entries', () => { + const data = defaultExtractData( + makePart({ + metadata: { + diagnostics: { + 'C:\\proj\\a.ts': [ + { severity: 2, message: 'warn' }, + { severity: 3, message: 'info' }, + { severity: 9, message: 'unknown severity' }, + { message: 'no severity' }, + null, + ], + 'b.ts': 'not-an-array', + }, + }, + }), + ) + expect(data.diagnostics).toEqual([{ file: 'a.ts', severity: 'warning', message: 'warn', line: 0, column: 0 }]) + }) + + it('omits diagnostics when nothing is an error or warning', () => { + const data = defaultExtractData(makePart({ metadata: { diagnostics: { 'a.ts': [{ severity: 4 }] } } })) + expect(data.diagnostics).toBeUndefined() + }) + + it('uses state.output, stringifying non-string values and inferring json', () => { + expect(defaultExtractData(makePart({ output: 'plain' }))).toMatchObject({ output: 'plain' }) + expect(defaultExtractData(makePart({ output: 'plain' })).outputLang).toBeUndefined() + expect(defaultExtractData(makePart({ output: ' {"a":1} ' })).outputLang).toBe('json') + expect(defaultExtractData(makePart({ output: '[1]' })).outputLang).toBe('json') + expect(defaultExtractData(makePart({ output: { a: 1 } })).output).toBe(JSON.stringify({ a: 1 }, null, 2)) + }) + + it('falls back to the interrupted metadata output only when interrupted', () => { + expect(defaultExtractData(makePart({ metadata: { interrupted: true, output: 'partial' } })).output).toBe('partial') + expect(defaultExtractData(makePart({ metadata: { output: 'partial' } })).output).toBeUndefined() + }) + + it('does not set output when files are present', () => { + const data = defaultExtractData(makePart({ output: 'x', metadata: { files: [{ filePath: 'a' }] } })) + expect(data.output).toBeUndefined() + }) +}) + +describe('extractToolData for bash', () => { + const bash = (state: Record) => extractToolData({ ...makePart(state), tool: 'bash' } as ToolPart) + + it('shows the command as bash input and trims the working directory', () => { + const data = bash({ input: { command: 'ls -la', workdir: ' /tmp ' } }) + expect(data).toMatchObject({ input: 'ls -la', inputLang: 'bash', cwd: '/tmp' }) + }) + + it('falls back to metadata cwd and ignores blank or non-string values', () => { + expect(bash({ input: { command: 'pwd' }, metadata: { cwd: '/srv' } }).cwd).toBe('/srv') + expect(bash({ input: { command: 'pwd', workdir: ' ' } }).cwd).toBeUndefined() + expect(bash({ input: { command: 'pwd', cwd: 5 } }).cwd).toBeUndefined() + }) + + it('keeps the JSON input when there is no command', () => { + expect(bash({ input: { a: 1 } }).inputLang).toBe('json') + }) +}) diff --git a/src/features/message/tools/registry.tsx b/src/features/message/tools/registry.tsx index afa491a..f411b21 100644 --- a/src/features/message/tools/registry.tsx +++ b/src/features/message/tools/registry.tsx @@ -60,6 +60,156 @@ interface MetadataDiagnosticEntry { // Default Data Extractor // ============================================ +type DiffStats = { additions: number; deletions: number } + +type MetadataFileDiff = { + patch?: string + before?: string + after?: string + additions?: number + deletions?: number +} + +function toDiffStats(fd: { additions?: number; deletions?: number }): DiffStats | undefined { + if (fd.additions === undefined && fd.deletions === undefined) return undefined + return { additions: fd.additions || 0, deletions: fd.deletions || 0 } +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object' +} + +function toFileEntries(files: MetadataFileEntry[]): NonNullable { + return files.map(file => ({ + filePath: file.filePath || file.file || 'unknown', + diff: file.diff, + patch: file.patch, + before: file.before, + after: file.after, + additions: file.additions, + deletions: file.deletions, + })) +} + +/** 上游 v1.4.0+ metadata.filediff 用 patch 格式;否则回退到 before/after */ +function diffFromFileDiff(filediff: MetadataFileDiff): ExtractedToolData['diff'] { + if (filediff.patch) return filediff.patch + if (filediff.before !== undefined && filediff.after !== undefined) { + return { before: filediff.before, after: filediff.after } + } + return undefined +} + +/** Extracts files / diff / diffStats from tool metadata (files > unified diff > filediff). */ +function extractDiffData(metadata: Record): Pick { + if (Array.isArray(metadata.files) && metadata.files.length > 0) { + return { files: toFileEntries(metadata.files as MetadataFileEntry[]) } + } + + const filediff = isObject(metadata.filediff) ? (metadata.filediff as MetadataFileDiff) : undefined + const diffStats = filediff && toDiffStats(filediff) + + // 优先使用 unified diff,统计取自 filediff + if (typeof metadata.diff === 'string') return { diff: metadata.diff, diffStats } + if (!filediff) return {} + return { diff: diffFromFileDiff(filediff), diffStats } +} + +// severity: 1=error, 2=warning, 3=info, 4=hint +const DIAGNOSTIC_SEVERITIES: Record = { + 1: 'error', + 2: 'warning', + 3: 'info', + 4: 'hint', +} + +function toSeverity(value: unknown): DiagnosticInfo['severity'] { + return (typeof value === 'number' && DIAGNOSTIC_SEVERITIES[value]) || 'info' +} + +function toDiagnostic(file: string, item: MetadataDiagnosticEntry): DiagnosticInfo { + const start = item.range?.start + return { + file: file.split(/[/\\]/).pop() || file, + severity: toSeverity(item.severity), + message: item.message || '', + line: start?.line ?? 0, + column: start?.character ?? 0, + } +} + +/** Extracts only the error/warning diagnostics from tool metadata. */ +function extractDiagnostics(raw: unknown): DiagnosticInfo[] | undefined { + if (!isObject(raw)) return undefined + + const diagnostics: DiagnosticInfo[] = [] + for (const [file, items] of Object.entries(raw as Record)) { + if (!Array.isArray(items)) continue + for (const item of items) { + if (isObject(item)) diagnostics.push(toDiagnostic(file, item)) + } + } + + // 只保留 error 和 warning + const filtered = diagnostics.filter(d => d.severity === 'error' || d.severity === 'warning') + return filtered.length > 0 ? filtered : undefined +} + +function looksLikeJson(text: string): boolean { + const trimmed = text.trim() + return (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']')) +} + +function extractFilePath( + inputObj: Record | undefined, + metadata: Record | undefined, +): string | undefined { + if (typeof metadata?.filepath === 'string' && metadata.filepath) return metadata.filepath + return inputObj?.filePath ? String(inputObj.filePath) : undefined +} + +/** Tool output (or the output kept by an interrupted run), stringified. */ +function extractOutputText( + state: ToolPart['state'] | undefined, + metadata: Record | undefined, +): string | undefined { + const stateOutput = state && 'output' in state ? state.output : undefined + const interrupted = + metadata?.interrupted === true && typeof metadata.output === 'string' ? metadata.output : undefined + const output = stateOutput ?? interrupted + if (!output) return undefined + return typeof output === 'string' ? output : JSON.stringify(output, null, 2) +} + +/** Fields derived from `metadata`: exit code, files / diff / diffStats and diagnostics. */ +function extractMetadataFields(metadata: Record | undefined): ExtractedToolData { + if (!metadata) return {} + + const result: ExtractedToolData = {} + if (typeof metadata.exit === 'number') result.exitCode = metadata.exit + + const diffData = extractDiffData(metadata) + if (diffData.files) result.files = diffData.files + if (diffData.diff !== undefined) result.diff = diffData.diff + if (diffData.diffStats) result.diffStats = diffData.diffStats + + const diagnostics = extractDiagnostics(metadata.diagnostics) + if (diagnostics) result.diagnostics = diagnostics + return result +} + +/** Output text plus its language (inferred as json when it looks like a JSON document). */ +function extractOutputFields( + result: ExtractedToolData, + state: ToolPart['state'] | undefined, + metadata: Record | undefined, +): ExtractedToolData { + const output = extractOutputText(state, metadata) + if (result.files || result.diff || !output) return {} + if (!result.outputLang && looksLikeJson(output)) return { output, outputLang: 'json' } + return { output } +} + export function defaultExtractData(part: ToolPart): ExtractedToolData { const { state } = part const inputObj = state?.input as Record | undefined @@ -78,120 +228,15 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData { result.error = String(state.error) } - // FilePath - if (metadata && typeof metadata.filepath === 'string') { - result.filePath = metadata.filepath - } - if (!result.filePath && inputObj?.filePath) { - result.filePath = String(inputObj.filePath) + // FilePath (+ output language derived from it) + const filePath = extractFilePath(inputObj, metadata) + if (filePath) { + result.filePath = filePath + result.outputLang = detectLanguage(filePath) } - // Exit code - if (metadata && typeof metadata.exit === 'number') { - result.exitCode = metadata.exit - } - - // Diff / Files (from metadata) - if (metadata) { - if (Array.isArray(metadata.files) && metadata.files.length > 0) { - result.files = (metadata.files as MetadataFileEntry[]).map(file => ({ - filePath: file.filePath || file.file || 'unknown', - diff: file.diff, - patch: file.patch, - before: file.before, - after: file.after, - additions: file.additions, - deletions: file.deletions, - })) - } else if (typeof metadata.diff === 'string') { - // 优先使用 unified diff - result.diff = metadata.diff - // 从 filediff 获取统计 - if (metadata.filediff && typeof metadata.filediff === 'object') { - const fd = metadata.filediff as { additions?: number; deletions?: number } - if (fd.additions !== undefined || fd.deletions !== undefined) { - result.diffStats = { - additions: fd.additions || 0, - deletions: fd.deletions || 0, - } - } - } - } else if (metadata.filediff && typeof metadata.filediff === 'object') { - const fd = metadata.filediff as { - patch?: string - before?: string - after?: string - additions?: number - deletions?: number - } - // 上游 v1.4.0+ metadata.filediff 用 patch 格式 - if (fd.patch) { - result.diff = fd.patch - } else if (fd.before !== undefined && fd.after !== undefined) { - result.diff = { before: fd.before, after: fd.after } - } - if (fd.additions !== undefined || fd.deletions !== undefined) { - result.diffStats = { - additions: fd.additions || 0, - deletions: fd.deletions || 0, - } - } - } - - // 提取 diagnostics - if (metadata.diagnostics && typeof metadata.diagnostics === 'object') { - const diagMap = metadata.diagnostics as Record - const diagnostics: DiagnosticInfo[] = [] - - for (const [file, items] of Object.entries(diagMap)) { - if (!Array.isArray(items)) continue - for (const item of items) { - if (!item || typeof item !== 'object') continue - // severity: 1=error, 2=warning, 3=info, 4=hint - const severityMap: Record = { - 1: 'error', - 2: 'warning', - 3: 'info', - 4: 'hint', - } - diagnostics.push({ - file: file.split(/[/\\]/).pop() || file, - severity: typeof item.severity === 'number' ? (severityMap[item.severity] ?? 'info') : 'info', - message: item.message || '', - line: item.range?.start?.line ?? 0, - column: item.range?.start?.character ?? 0, - }) - } - } - - // 只保留 error 和 warning - const filtered = diagnostics.filter(d => d.severity === 'error' || d.severity === 'warning') - if (filtered.length > 0) { - result.diagnostics = filtered - } - } - } - - // Output language from filePath - if (result.filePath) { - result.outputLang = detectLanguage(result.filePath) - } - - // Output - const stateOutput = state && 'output' in state ? state.output : undefined - const interruptedOutput = metadata?.interrupted === true && typeof metadata.output === 'string' ? metadata.output : undefined - const output = stateOutput ?? interruptedOutput - if (!result.files && !result.diff && output) { - result.output = typeof output === 'string' ? output : JSON.stringify(output, null, 2) - - // 推断语言 - if (!result.outputLang && result.output) { - const trimmed = result.output.trim() - if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { - result.outputLang = 'json' - } - } - } + Object.assign(result, extractMetadataFields(metadata)) + Object.assign(result, extractOutputFields(result, state, metadata)) return result } @@ -200,6 +245,14 @@ export function defaultExtractData(part: ToolPart): ExtractedToolData { // Tool-Specific Data Extractors // ============================================ +function resolveCwd( + inputObj: Record | undefined, + metadata: Record | undefined, +): string | undefined { + const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd + return typeof cwd === 'string' && cwd.trim() ? cwd.trim() : undefined +} + function bashExtractData(part: ToolPart): ExtractedToolData { const base = defaultExtractData(part) const inputObj = part.state?.input as Record | undefined @@ -210,10 +263,8 @@ function bashExtractData(part: ToolPart): ExtractedToolData { base.inputLang = 'bash' } - const cwd = inputObj?.workdir ?? inputObj?.cwd ?? metadata?.workdir ?? metadata?.cwd - if (typeof cwd === 'string' && cwd.trim()) { - base.cwd = cwd.trim() - } + const cwd = resolveCwd(inputObj, metadata) + if (cwd) base.cwd = cwd return base } diff --git a/src/store/layoutStore.sanitize.test.ts b/src/store/layoutStore.sanitize.test.ts new file mode 100644 index 0000000..e47d613 --- /dev/null +++ b/src/store/layoutStore.sanitize.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { sanitizePersistedTerminalLayoutMap } from './layoutStore' + +const EMPTY = { version: 1, directories: {} } + +function entry(overrides: Record = {}) { + return { + order: { bottom: ['a'], right: ['b'] }, + activeTabId: { bottom: 'a', right: 'b' }, + ...overrides, + } +} + +function sanitize(directories: Record) { + return sanitizePersistedTerminalLayoutMap({ version: 1, directories }) +} + +describe('sanitizePersistedTerminalLayoutMap', () => { + it.each([null, undefined, 'x', 3, { version: 2, directories: {} }, { version: 1 }, { version: 1, directories: 'x' }])( + 'falls back to an empty map for invalid root %j', + raw => { + expect(sanitizePersistedTerminalLayoutMap(raw)).toEqual(EMPTY) + }, + ) + + it('keeps a well-formed directory entry', () => { + expect(sanitize({ '/proj': entry() })).toEqual({ + version: 1, + directories: { + '/proj': { order: { bottom: ['a'], right: ['b'] }, activeTabId: { bottom: 'a', right: 'b' }, sessions: {} }, + }, + }) + }) + + it('skips entries with an empty key, a non-object value, or missing order / activeTabId', () => { + const result = sanitize({ + '': entry(), + '/null': null, + '/string': 'x', + '/no-order': { activeTabId: { bottom: null, right: null } }, + '/order-not-object': entry({ order: 'x' }), + '/no-active': { order: { bottom: [], right: [] } }, + '/active-not-object': entry({ activeTabId: 'x' }), + '/ok': entry(), + }) + expect(Object.keys(result.directories)).toEqual(['/ok']) + }) + + it('filters the order lists down to non-empty strings and tolerates non-arrays', () => { + const result = sanitize({ + '/a': entry({ order: { bottom: ['x', '', 5, null, 'y'], right: 'nope' } }), + }) + expect(result.directories['/a'].order).toEqual({ bottom: ['x', 'y'], right: [] }) + }) + + it('nulls non-string active tab ids independently', () => { + const result = sanitize({ + '/a': entry({ activeTabId: { bottom: 'a', right: 7 } }), + '/b': entry({ activeTabId: { bottom: 7, right: 'b' } }), + }) + expect(result.directories['/a'].activeTabId).toEqual({ bottom: 'a', right: null }) + expect(result.directories['/b'].activeTabId).toEqual({ bottom: null, right: 'b' }) + }) + + it('keeps typed session fields, drops mistyped ones and skips invalid sessions', () => { + const result = sanitize({ + '/a': entry({ + sessions: { + t1: { title: 'T', shellTitle: 's', customTitle: 'c', buffer: 'b', scrollY: 1, cursor: 2, rows: 3, cols: 4 }, + t2: { title: 5, shellTitle: 5, customTitle: 5, buffer: 5, scrollY: '1', cursor: '2', rows: '3', cols: '4' }, + '': { title: 'ignored' }, + t3: null, + t4: 'x', + }, + }), + }) + expect(result.directories['/a'].sessions).toEqual({ + t1: { title: 'T', shellTitle: 's', customTitle: 'c', buffer: 'b', scrollY: 1, cursor: 2, rows: 3, cols: 4 }, + t2: { + title: undefined, + shellTitle: undefined, + customTitle: undefined, + buffer: undefined, + scrollY: undefined, + cursor: undefined, + rows: undefined, + cols: undefined, + }, + }) + }) + + it('uses an empty sessions map when sessions is not an object', () => { + expect(sanitize({ '/a': entry({ sessions: 'x' }) }).directories['/a'].sessions).toEqual({}) + expect(sanitize({ '/a': entry() }).directories['/a'].sessions).toEqual({}) + }) +}) diff --git a/src/store/layoutStore.ts b/src/store/layoutStore.ts index 1b21a3e..dd63eee 100644 --- a/src/store/layoutStore.ts +++ b/src/store/layoutStore.ts @@ -230,61 +230,73 @@ function sanitizePersistedPanelLayout(raw: unknown): PersistedPanelLayout | null } } -function sanitizePersistedTerminalLayoutMap(raw: unknown): PersistedTerminalLayoutMap { - if (!raw || typeof raw !== 'object') { - return { version: 1, directories: {} } - } +function sanitizeIdList(raw: unknown): string[] { + return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string' && id.length > 0) : [] +} - const data = raw as Partial - if (data.version !== 1 || !data.directories || typeof data.directories !== 'object') { +function sanitizeActiveTabId(raw: { bottom?: unknown; right?: unknown }): Record { + return { + bottom: typeof raw.bottom === 'string' ? raw.bottom : null, + right: typeof raw.right === 'string' ? raw.right : null, + } +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function optionalNumber(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined +} + +function sanitizeTerminalSessionState(raw: Partial): PersistedTerminalSessionState { + return { + title: optionalString(raw.title), + shellTitle: optionalString(raw.shellTitle), + customTitle: optionalString(raw.customTitle), + buffer: optionalString(raw.buffer), + scrollY: optionalNumber(raw.scrollY), + cursor: optionalNumber(raw.cursor), + rows: optionalNumber(raw.rows), + cols: optionalNumber(raw.cols), + } +} + +function sanitizeTerminalSessions(raw: unknown): Record { + const sessions: Record = {} + if (!raw || typeof raw !== 'object') return sessions + + for (const [id, session] of Object.entries(raw)) { + if (!id || !session || typeof session !== 'object') continue + sessions[id] = sanitizeTerminalSessionState(session as Partial) + } + return sessions +} + +function sanitizeTerminalDirectoryLayout(raw: unknown): PersistedTerminalDirectoryLayout | null { + if (!raw || typeof raw !== 'object') return null + + const entry = raw as Partial + const { order, activeTabId } = entry + if (!order || typeof order !== 'object' || !activeTabId || typeof activeTabId !== 'object') return null + + return { + order: { bottom: sanitizeIdList(order.bottom), right: sanitizeIdList(order.right) }, + activeTabId: sanitizeActiveTabId(activeTabId), + sessions: sanitizeTerminalSessions(entry.sessions), + } +} + +export function sanitizePersistedTerminalLayoutMap(raw: unknown): PersistedTerminalLayoutMap { + const data = raw && typeof raw === 'object' ? (raw as Partial) : undefined + if (data?.version !== 1 || !data.directories || typeof data.directories !== 'object') { return { version: 1, directories: {} } } const directories: Record = {} - for (const [directory, value] of Object.entries(data.directories)) { - if (!directory || !value || typeof value !== 'object') continue - const entry = value as Partial - const rawOrder = entry.order - const rawActiveTabId = entry.activeTabId - if (!rawOrder || typeof rawOrder !== 'object' || !rawActiveTabId || typeof rawActiveTabId !== 'object') continue - - const order = { - bottom: Array.isArray(rawOrder.bottom) - ? rawOrder.bottom.filter((id): id is string => typeof id === 'string' && id.length > 0) - : [], - right: Array.isArray(rawOrder.right) - ? rawOrder.right.filter((id): id is string => typeof id === 'string' && id.length > 0) - : [], - } - - const sessions: Record = {} - const rawSessions = entry.sessions - if (rawSessions && typeof rawSessions === 'object') { - for (const [id, session] of Object.entries(rawSessions)) { - if (!id || !session || typeof session !== 'object') continue - const data = session as Partial - sessions[id] = { - title: typeof data.title === 'string' ? data.title : undefined, - shellTitle: typeof data.shellTitle === 'string' ? data.shellTitle : undefined, - customTitle: typeof data.customTitle === 'string' ? data.customTitle : undefined, - buffer: typeof data.buffer === 'string' ? data.buffer : undefined, - scrollY: typeof data.scrollY === 'number' ? data.scrollY : undefined, - cursor: typeof data.cursor === 'number' ? data.cursor : undefined, - rows: typeof data.rows === 'number' ? data.rows : undefined, - cols: typeof data.cols === 'number' ? data.cols : undefined, - } - } - } - - directories[directory] = { - order, - activeTabId: { - bottom: typeof rawActiveTabId.bottom === 'string' ? rawActiveTabId.bottom : null, - right: typeof rawActiveTabId.right === 'string' ? rawActiveTabId.right : null, - }, - sessions, - } + const layout = directory ? sanitizeTerminalDirectoryLayout(value) : null + if (layout) directories[directory] = layout } return { version: 1, directories } diff --git a/src/utils/diffStats.test.ts b/src/utils/diffStats.test.ts new file mode 100644 index 0000000..981d680 --- /dev/null +++ b/src/utils/diffStats.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { computeDiffPairStats, computeDiffStats } from './diffStats' + +describe('computeDiffPairStats', () => { + it('counts added and removed lines', () => { + expect(computeDiffPairStats('a\nb\n', 'a\nc\nd\n')).toEqual({ additions: 2, deletions: 1 }) + }) + + it('returns zeros for identical text', () => { + expect(computeDiffPairStats('a\n', 'a\n')).toEqual({ additions: 0, deletions: 0 }) + }) +}) + +describe('computeDiffStats', () => { + it('returns undefined without files or a before/after pair', () => { + expect(computeDiffStats({})).toBeUndefined() + expect(computeDiffStats({ files: [] })).toBeUndefined() + expect(computeDiffStats({ diff: '@@ -1 +1 @@' })).toBeUndefined() + }) + + it('sums the counters reported per file', () => { + expect( + computeDiffStats({ + files: [{ additions: 2, deletions: 1 }, { additions: 1 }, { deletions: 3 }], + }), + ).toEqual({ additions: 3, deletions: 4 }) + }) + + it('computes missing counters from before/after, only when additions is absent', () => { + expect( + computeDiffStats({ + files: [ + { before: 'a\n', after: 'a\nb\n' }, + { before: 'x\n', after: 'y\n', additions: 5 }, + { before: 'only-before\n' }, + ], + }), + ).toEqual({ additions: 6, deletions: 0 }) + }) + + it('returns undefined when files contribute no changes', () => { + expect(computeDiffStats({ files: [{ additions: 0, deletions: 0 }] })).toBeUndefined() + }) + + it('uses a single before/after diff and ignores it when nothing changed', () => { + expect(computeDiffStats({ diff: { before: 'a\n', after: 'b\n' } })).toEqual({ additions: 1, deletions: 1 }) + expect(computeDiffStats({ diff: { before: 'a\n', after: 'a\n' } })).toBeUndefined() + }) + + it('prefers files over the single diff', () => { + expect(computeDiffStats({ files: [{ additions: 1 }], diff: { before: 'a\n', after: 'b\nc\n' } })).toEqual({ + additions: 1, + deletions: 0, + }) + }) +}) diff --git a/src/utils/diffStats.ts b/src/utils/diffStats.ts new file mode 100644 index 0000000..7cd9f6d --- /dev/null +++ b/src/utils/diffStats.ts @@ -0,0 +1,50 @@ +import { diffLines } from 'diff' + +export interface DiffStats { + additions: number + deletions: number +} + +interface DiffStatsInput { + diff?: { before: string; after: string } | string + files?: Array<{ before?: string; after?: string; additions?: number; deletions?: number }> +} + +/** Line additions/deletions between two texts. */ +export function computeDiffPairStats(before: string, after: string): DiffStats { + let additions = 0 + let deletions = 0 + for (const change of diffLines(before, after)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + return { additions, deletions } +} + +/** + * Diff stats from extracted tool data, for when the metadata did not provide them. + * Returns undefined when there is nothing to count (no files, no before/after pair, or no changes). + */ +export function computeDiffStats(data: DiffStatsInput): DiffStats | undefined { + if (data.files?.length) { + let additions = 0 + let deletions = 0 + for (const file of data.files) { + if (file.additions !== undefined) additions += file.additions + if (file.deletions !== undefined) deletions += file.deletions + if (file.additions === undefined && file.before !== undefined && file.after !== undefined) { + const stats = computeDiffPairStats(file.before, file.after) + additions += stats.additions + deletions += stats.deletions + } + } + return additions || deletions ? { additions, deletions } : undefined + } + + if (data.diff && typeof data.diff === 'object') { + const stats = computeDiffPairStats(data.diff.before, data.diff.after) + return stats.additions || stats.deletions ? stats : undefined + } + + return undefined +}