From e1585b4f713fccd8e5f74d4d8ba6778530487817 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 1 Mar 2026 23:51:51 +0200 Subject: [PATCH] feat: add binary file preview functionality and improve editor performance - Introduced a new IPC handler for reading binary files as base64 for inline previews, enhancing the editor's capability to display images directly. - Implemented a placeholder component for non-previewable binary files, providing users with file information and an option to open in the system viewer. - Enhanced the EditorFileWatcher to ignore permission errors and adjusted debounce timing for better performance during file system events. - Updated the ProjectFileService to include a method for reading binary previews with security checks and MIME type validation. - Improved the EditorImagePreview component to handle loading states and display images with a checkerboard background for transparency. - Refactored the EditorBinaryState component to utilize the new binary preview functionality, streamlining the display of binary files. --- src/main/ipc/editor.ts | 25 +- src/main/services/editor/EditorFileWatcher.ts | 6 +- src/main/services/editor/FileSearchService.ts | 47 +-- src/main/services/editor/GitStatusService.ts | 11 +- .../services/editor/ProjectFileService.ts | 98 +++++- .../infrastructure/CliInstallerService.ts | 44 ++- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 4 + src/renderer/api/httpClient.ts | 3 + .../components/team/TeamDetailView.tsx | 29 +- .../team/dialogs/CreateTaskDialog.tsx | 30 +- .../team/dialogs/SendMessageDialog.tsx | 30 +- .../team/editor/EditorBinaryPlaceholder.tsx | 42 +++ .../team/editor/EditorBinaryState.tsx | 33 +- .../team/editor/EditorBreadcrumb.tsx | 27 +- .../team/editor/EditorContextMenu.tsx | 2 +- .../components/team/editor/EditorFileTree.tsx | 21 +- .../team/editor/EditorImagePreview.tsx | 131 ++++++++ .../team/editor/EditorSearchPanel.tsx | 52 ++-- .../team/editor/EditorStatusBar.tsx | 29 +- .../components/team/editor/EditorToolbar.tsx | 48 +-- .../team/editor/ProjectEditorOverlay.tsx | 6 + .../team/messages/MessageComposer.tsx | 26 +- .../components/ui/ChipInteractionLayer.tsx | 178 +++++++++++ src/renderer/components/ui/CodeChipBadge.tsx | 32 ++ .../components/ui/MentionableTextarea.tsx | 284 ++++++++++++++++-- src/renderer/hooks/useChipDraftPersistence.ts | 129 ++++++++ src/renderer/index.css | 34 +++ src/renderer/store/slices/editorSlice.ts | 193 ++++++++++-- src/renderer/types/inlineChip.ts | 81 +++++ src/renderer/utils/chipUtils.ts | 245 +++++++++++++++ src/renderer/utils/previewRegistry.ts | 45 +++ src/shared/types/editor.ts | 14 + .../services/editor/EditorFileWatcher.test.ts | 8 +- .../CliInstallerService.test.ts | 113 ++++++- .../hooks/useChipDraftPersistence.test.ts | 96 ++++++ test/renderer/types/inlineChip.test.ts | 122 ++++++++ test/renderer/utils/chipUtils.test.ts | 224 ++++++++++++++ 38 files changed, 2372 insertions(+), 173 deletions(-) create mode 100644 src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx create mode 100644 src/renderer/components/team/editor/EditorImagePreview.tsx create mode 100644 src/renderer/components/ui/ChipInteractionLayer.tsx create mode 100644 src/renderer/components/ui/CodeChipBadge.tsx create mode 100644 src/renderer/hooks/useChipDraftPersistence.ts create mode 100644 src/renderer/types/inlineChip.ts create mode 100644 src/renderer/utils/chipUtils.ts create mode 100644 src/renderer/utils/previewRegistry.ts create mode 100644 test/renderer/hooks/useChipDraftPersistence.test.ts create mode 100644 test/renderer/types/inlineChip.test.ts create mode 100644 test/renderer/utils/chipUtils.test.ts diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts index cb901c99..d6211078 100644 --- a/src/main/ipc/editor.ts +++ b/src/main/ipc/editor.ts @@ -17,6 +17,7 @@ import { EDITOR_LIST_FILES, EDITOR_MOVE_FILE, EDITOR_OPEN, + EDITOR_READ_BINARY_PREVIEW, EDITOR_READ_DIR, EDITOR_READ_FILE, EDITOR_RENAME_FILE, @@ -41,6 +42,7 @@ import { import { createIpcWrapper } from './ipcWrapper'; import type { + BinaryPreviewResult, CreateDirResponse, CreateFileResponse, DeleteFileResponse, @@ -307,6 +309,19 @@ async function handleEditorGitStatus(): Promise> { }); } +/** + * Read binary file as base64 for inline preview. + */ +async function handleEditorReadBinaryPreview( + _event: IpcMainInvokeEvent, + filePath: string +): Promise> { + return wrapHandler('readBinaryPreview', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + return projectFileService.readBinaryPreview(activeProjectRoot, filePath); + }); +} + /** * Enable/disable file watcher for current project. */ @@ -319,8 +334,12 @@ async function handleEditorWatchDir( if (enable) { editorFileWatcher.start(activeProjectRoot, (event) => { - // Invalidate git cache on file changes - gitStatusService.invalidateCache(); + // Git invalidation can be expensive: invalidating on every "change" causes us to + // re-run git status repeatedly during editor activity or build bursts. + // Instead, invalidate only on structural changes (create/delete). + if (event.type === 'create' || event.type === 'delete') { + gitStatusService.invalidateCache(); + } // Forward event to renderer if (mainWindowRef && !mainWindowRef.isDestroyed()) { @@ -362,6 +381,7 @@ export function registerEditorHandlers(ipcMain: IpcMain): void { ipcMain.handle(EDITOR_RENAME_FILE, handleEditorRenameFile); ipcMain.handle(EDITOR_SEARCH_IN_FILES, handleEditorSearchInFiles); ipcMain.handle(EDITOR_LIST_FILES, handleEditorListFiles); + ipcMain.handle(EDITOR_READ_BINARY_PREVIEW, handleEditorReadBinaryPreview); ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus); ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir); } @@ -379,6 +399,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(EDITOR_RENAME_FILE); ipcMain.removeHandler(EDITOR_SEARCH_IN_FILES); ipcMain.removeHandler(EDITOR_LIST_FILES); + ipcMain.removeHandler(EDITOR_READ_BINARY_PREVIEW); ipcMain.removeHandler(EDITOR_GIT_STATUS); ipcMain.removeHandler(EDITOR_WATCH_DIR); } diff --git a/src/main/services/editor/EditorFileWatcher.ts b/src/main/services/editor/EditorFileWatcher.ts index 8c1715cb..0fdd5b14 100644 --- a/src/main/services/editor/EditorFileWatcher.ts +++ b/src/main/services/editor/EditorFileWatcher.ts @@ -24,7 +24,7 @@ const log = createLogger('EditorFileWatcher'); /** Directories to ignore (regex for chokidar's `ignored` option) */ const IGNORED_PATTERN = - /(node_modules|\.git|dist|__pycache__|\.cache|\.next|\.venv|\.tox|vendor|\.DS_Store)/; + /(node_modules|\.git|dist|build|out|coverage|__pycache__|\.cache|\.next|\.turbo|\.parcel-cache|\.vite|\.venv|\.tox|vendor|target|Pods|DerivedData|\.idea|\.vscode|\.DS_Store)/; const MAX_DEPTH = 20; @@ -38,7 +38,8 @@ export class EditorFileWatcher { private pendingEvents = new Map(); private flushTimer: ReturnType | null = null; private onChangeCallback: ((event: EditorFileChangeEvent) => void) | null = null; - private readonly DEBOUNCE_MS = 150; + // Higher debounce = fewer IPC events during large bursts (checkout/build/format). + private readonly DEBOUNCE_MS = 350; /** * Start watching a project directory. @@ -53,6 +54,7 @@ export class EditorFileWatcher { this.watcher = watch(projectRoot, { ignored: IGNORED_PATTERN, ignoreInitial: true, + ignorePermissionErrors: true, followSymlinks: false, depth: MAX_DEPTH, }); diff --git a/src/main/services/editor/FileSearchService.ts b/src/main/services/editor/FileSearchService.ts index 63aba750..eb85bfe3 100644 --- a/src/main/services/editor/FileSearchService.ts +++ b/src/main/services/editor/FileSearchService.ts @@ -259,6 +259,8 @@ export class FileSearchService { /** * Search a single file for literal string matches. + * Uses indexOf on the full content instead of split() — avoids 50k+ string allocations + * for a 1MB file. Running line counter keeps O(n) total regardless of match count. */ private async searchFile( filePath: string, @@ -268,29 +270,38 @@ export class FileSearchService { ): Promise { if (signal?.aborted) return []; - const content = await fs.readFile(filePath, 'utf8'); - const lines = content.split('\n'); + const raw = await fs.readFile(filePath, 'utf8'); + const content = caseSensitive ? raw : raw.toLowerCase(); const matches: SearchMatch[] = []; - for (let i = 0; i < lines.length; i++) { + let searchFrom = 0; + let line = 1; + let lastCountedPos = 0; + + while (true) { if (signal?.aborted) break; + const idx = content.indexOf(query, searchFrom); + if (idx === -1) break; - const line = lines[i]; - const searchLine = caseSensitive ? line : line.toLowerCase(); - let startIndex = 0; - - while (true) { - const idx = searchLine.indexOf(query, startIndex); - if (idx === -1) break; - - matches.push({ - line: i + 1, - column: idx, - lineContent: line.trim(), - }); - - startIndex = idx + query.length; + // Running line counter — count \n from lastCountedPos to idx + for (let i = lastCountedPos; i < idx; i++) { + if (content.charCodeAt(i) === 10) line++; } + lastCountedPos = idx; + + // Extract line content from raw text + let lineStart = raw.lastIndexOf('\n', idx); + lineStart = lineStart === -1 ? 0 : lineStart + 1; + let lineEnd = raw.indexOf('\n', idx); + if (lineEnd === -1) lineEnd = raw.length; + + matches.push({ + line, + column: idx - lineStart, + lineContent: raw.slice(lineStart, lineEnd).trim(), + }); + + searchFrom = idx + query.length; } return matches; diff --git a/src/main/services/editor/GitStatusService.ts b/src/main/services/editor/GitStatusService.ts index 1078ab61..d0eb5a52 100644 --- a/src/main/services/editor/GitStatusService.ts +++ b/src/main/services/editor/GitStatusService.ts @@ -20,6 +20,7 @@ const log = createLogger('GitStatusService'); const GIT_TIMEOUT_MS = 10_000; const CACHE_TTL_MS = 5_000; +const GIT_STATUS_ARGS: string[] = ['--untracked-files=no']; // ============================================================================= // Service @@ -75,13 +76,21 @@ export class GitStatusService { // Return cached if fresh if (this.cachedResult && Date.now() - this.cacheTimestamp < CACHE_TTL_MS) { + log.info('[perf] gitStatus: cache hit'); return this.cachedResult; } try { - const statusResult = await this.git.status(); + const t0 = performance.now(); + // `--untracked-files=no` is a major performance win for large repos with many untracked files. + // Most editors treat untracked as a separate concern; showing them can overwhelm the UI. + const statusResult = await this.git.status(GIT_STATUS_ARGS); + const gitMs = performance.now() - t0; const files = mapStatusResult(statusResult); const branch = statusResult.current ?? null; + log.info( + `[perf] gitStatus: git=${gitMs.toFixed(1)}ms, files=${files.length}, branch=${branch}, untracked=off` + ); const result: GitStatusResult = { files, isGitRepo: true, branch }; this.setCacheResult(result); diff --git a/src/main/services/editor/ProjectFileService.ts b/src/main/services/editor/ProjectFileService.ts index 53587ff9..e203770c 100644 --- a/src/main/services/editor/ProjectFileService.ts +++ b/src/main/services/editor/ProjectFileService.ts @@ -23,6 +23,7 @@ import { isBinaryFile } from 'isbinaryfile'; import * as path from 'path'; import type { + BinaryPreviewResult, CreateDirResponse, CreateFileResponse, DeleteFileResponse, @@ -43,6 +44,32 @@ const MAX_WRITE_SIZE = 2 * 1024 * 1024; // 2 MB const MAX_DIR_ENTRIES = 500; const PREVIEW_LINE_COUNT = 100; +/** + * Extract the first N lines from text using indexOf — O(1) allocations vs split(). + * For a 5MB file with 100k lines, avoids creating 100k string objects. + */ +function sliceFirstNLines(text: string, n: number): string { + let pos = 0; + for (let i = 0; i < n; i++) { + const next = text.indexOf('\n', pos); + if (next === -1) return text; + pos = next + 1; + } + return text.slice(0, pos > 0 ? pos - 1 : 0); +} + +const PREVIEW_MIME_MAP: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', +}; +const MAX_PREVIEW_SIZE = 10 * 1024 * 1024; // 10 MB + const IGNORED_DIRS = new Set([ '.git', 'node_modules', @@ -77,6 +104,7 @@ export class ProjectFileService { dirPath: string, maxEntries: number = MAX_DIR_ENTRIES ): Promise { + const t0 = performance.now(); const normalizedDir = path.resolve(dirPath); // Containment check (allow sensitive files to be listed with flag) @@ -151,6 +179,13 @@ export class ProjectFileService { return a.name.localeCompare(b.name); }); + const totalMs = performance.now() - t0; + if (totalMs > 50) { + log.info( + `[perf] readDir: ${totalMs.toFixed(1)}ms, entries=${entries.length}, dirents=${dirents.length}, dir=${path.basename(normalizedDir)}` + ); + } + return { entries, truncated: pendingEntries.length >= maxEntries }; } @@ -216,7 +251,7 @@ export class ProjectFileService { // 8. Tiered response const isPreview = stats.size > MAX_FILE_SIZE_FULL; - const content = isPreview ? raw.split('\n').slice(0, PREVIEW_LINE_COUNT).join('\n') : raw; + const content = isPreview ? sliceFirstNLines(raw, PREVIEW_LINE_COUNT) : raw; return { content, @@ -613,6 +648,67 @@ export class ProjectFileService { return { newPath, isDirectory }; } + /** + * Read a binary file as base64 for inline preview (images, etc.). + * + * Security: + * - validateFilePath for traversal + sensitive check (SEC-1) + * - Device path blocking (SEC-4) + * - lstat + isFile check (SEC-4) + * - Size limit (10MB) + * - Post-read TOCTOU realpath verify (SEC-3) + */ + async readBinaryPreview(projectRoot: string, filePath: string): Promise { + // 1. Path validation + const validation = validateFilePath(filePath, projectRoot); + if (!validation.valid) { + throw new Error(validation.error); + } + + const normalizedPath = validation.normalizedPath!; + + // 2. Device path block + if (isDevicePath(normalizedPath)) { + throw new Error('Cannot read device files'); + } + + // 3. File type check + const stats = await fs.lstat(normalizedPath); + if (!stats.isFile()) { + throw new Error('Not a regular file'); + } + + // 4. Size check + if (stats.size > MAX_PREVIEW_SIZE) { + throw new Error( + `File too large for preview (${(stats.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.` + ); + } + + // 5. MIME type from extension + const ext = path.extname(normalizedPath).toLowerCase(); + const mimeType = PREVIEW_MIME_MAP[ext]; + if (!mimeType) { + throw new Error(`Unsupported preview format: ${ext}`); + } + + // 6. Read file as Buffer → base64 + const buffer = await fs.readFile(normalizedPath); + + // 7. Post-read TOCTOU verify + const realPath = await fs.realpath(normalizedPath); + const postValidation = validateFilePath(realPath, projectRoot); + if (!postValidation.valid) { + throw new Error('Path changed during read (TOCTOU)'); + } + + return { + base64: buffer.toString('base64'), + mimeType, + size: stats.size, + }; + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 77ef7bc7..7d89e324 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -58,7 +58,10 @@ const MAX_REDIRECTS = 5; const HTTP_CONNECT_TIMEOUT_MS = 15_000; /** Overall timeout for getStatus() to prevent UI hanging indefinitely (ms) */ -const GET_STATUS_TIMEOUT_MS = 25_000; +const GET_STATUS_TIMEOUT_MS = 30_000; + +/** Overall timeout for the auth status check (covers both attempts + retry delay) (ms) */ +const AUTH_TOTAL_TIMEOUT_MS = 15_000; /** Max retries for EBUSY (antivirus scanning the new binary) */ const EBUSY_MAX_RETRIES = 3; @@ -269,6 +272,8 @@ export class CliInstallerService { * Gathers CLI status information, mutating the provided result object. * Split from getStatus() to enable overall timeout via Promise.race — * on timeout, getStatus() returns whatever fields were populated so far. + * + * Flow: binary resolve → --version (sequential) → Promise.all([auth, GCS]) (parallel) */ private async gatherStatus(ref: { current: CliInstallationStatus }): Promise { const r = ref.current; @@ -290,7 +295,22 @@ export class CliInstallerService { logger.warn('Failed to get CLI version:', getErrorMessage(err)); } - // Check auth status with retry — covers stale lock files after Ctrl+C interruption + // Auth and GCS version check are independent — run in parallel. + // Both mutate `r` directly so partial results survive the outer timeout. + await Promise.all([this.checkAuthStatus(binaryPath, r), this.fetchLatestVersion(r)]); + } else { + // No binary — still check latest version for "install" prompt + await this.fetchLatestVersion(r); + } + } + + /** + * Check auth status with retry — covers stale lock files after Ctrl+C interruption. + * Wrapped in its own timeout to prevent slow auth from blocking the overall status. + * Mutates `r` directly so results survive even if the outer Promise.all hasn't resolved. + */ + private async checkAuthStatus(binaryPath: string, r: CliInstallationStatus): Promise { + const doCheck = async (): Promise => { for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) { try { const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], { @@ -307,7 +327,7 @@ export class CliInstallerService { `Auth status: loggedIn=${r.authLoggedIn}, method=${r.authMethod ?? 'null'}` + (authAttempt > 1 ? ` (attempt ${authAttempt})` : '') ); - break; + return; } catch (err) { if (authAttempt < AUTH_STATUS_MAX_RETRIES) { logger.warn( @@ -323,8 +343,24 @@ export class CliInstallerService { } } } - } + }; + // Own timeout so slow auth doesn't eat the overall getStatus budget + await Promise.race([ + doCheck(), + new Promise((resolve) => + setTimeout(() => { + logger.warn(`Auth status check timed out after ${AUTH_TOTAL_TIMEOUT_MS}ms`); + resolve(); + }, AUTH_TOTAL_TIMEOUT_MS) + ), + ]); + } + + /** + * Fetch latest CLI version from GCS and update the result object. + */ + private async fetchLatestVersion(r: CliInstallationStatus): Promise { try { const latestRaw = await fetchText(`${GCS_BASE}/latest`); r.latestVersion = normalizeVersion(latestRaw); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 43ac301e..a0f9551e 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -458,5 +458,8 @@ export const EDITOR_GIT_STATUS = 'editor:gitStatus'; /** Enable/disable file watcher for current project */ export const EDITOR_WATCH_DIR = 'editor:watchDir'; +/** Read binary file as base64 for inline preview */ +export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview'; + /** File change event from watcher (main -> renderer) */ export const EDITOR_CHANGE = 'editor:change'; diff --git a/src/preload/index.ts b/src/preload/index.ts index fa564b06..ab23d69e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -19,6 +19,7 @@ import { EDITOR_LIST_FILES, EDITOR_MOVE_FILE, EDITOR_OPEN, + EDITOR_READ_BINARY_PREVIEW, EDITOR_READ_DIR, EDITOR_READ_FILE, EDITOR_RENAME_FILE, @@ -200,6 +201,7 @@ import type { WslClaudeRootCandidate, } from '@shared/types'; import type { + BinaryPreviewResult, CreateDirResponse, CreateFileResponse, DeleteFileResponse, @@ -1026,6 +1028,8 @@ const electronAPI: ElectronAPI = { searchInFiles: (options: SearchInFilesOptions) => invokeIpcWithResult(EDITOR_SEARCH_IN_FILES, options), listFiles: () => invokeIpcWithResult(EDITOR_LIST_FILES), + readBinaryPreview: (filePath: string) => + invokeIpcWithResult(EDITOR_READ_BINARY_PREVIEW, filePath), gitStatus: () => invokeIpcWithResult(EDITOR_GIT_STATUS), watchDir: (enable: boolean) => invokeIpcWithResult(EDITOR_WATCH_DIR, enable), onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index ff531760..f621c81a 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -979,6 +979,9 @@ export class HttpAPIClient implements ElectronAPI { listFiles: async () => { throw new Error('Editor not available in browser mode'); }, + readBinaryPreview: async () => { + throw new Error('Editor not available in browser mode'); + }, gitStatus: async () => { throw new Error('Editor not available in browser mode'); }, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index c628fb4c..57edcced 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -16,6 +16,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; +import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; @@ -75,6 +76,7 @@ import { TeamSessionsSection } from './TeamSessionsSection'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { MessagesFilterState } from './messages/MessagesFilterPopover'; import type { Session } from '@renderer/types/data'; +import type { InlineChip } from '@renderer/types/inlineChip'; import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; @@ -90,6 +92,7 @@ interface CreateTaskDialogState { defaultDescription: string; defaultOwner: string; defaultStartImmediately?: boolean; + defaultChip?: InlineChip; } interface TimeWindow { @@ -149,6 +152,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [trashOpen, setTrashOpen] = useState(false); const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); const [sendDialogDefaultText, setSendDialogDefaultText] = useState(undefined); + const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( + undefined + ); const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( undefined ); @@ -527,13 +533,26 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const handleEditorAction = useCallback( (action: EditorSelectionAction) => { + const chip = createChipFromSelection(action, []) ?? undefined; if (action.type === 'sendMessage') { - setSendDialogDefaultText(action.formattedContext); + setSendDialogDefaultText(chip ? undefined : action.formattedContext); + setSendDialogDefaultChip(chip); setSendDialogRecipient(undefined); setReplyQuote(undefined); setSendDialogOpen(true); } else if (action.type === 'createTask') { - openCreateTaskDialog('', action.formattedContext); + if (chip) { + setCreateTaskDialog({ + open: true, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + defaultStartImmediately: undefined, + defaultChip: chip, + }); + } else { + openCreateTaskDialog('', action.formattedContext); + } } }, @@ -926,6 +945,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onSendMessage={(member) => { setSendDialogRecipient(member.name); setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); setReplyQuote(undefined); setSendDialogOpen(true); }} @@ -1243,6 +1263,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onReplyToMessage={(message) => { setSendDialogRecipient(message.from); setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); setSendDialogOpen(true); }} @@ -1293,6 +1314,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setSelectedMember(null); setSendDialogRecipient(name || undefined); setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); setReplyQuote(undefined); setSendDialogOpen(true); }} @@ -1347,6 +1369,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele defaultDescription={createTaskDialog.defaultDescription} defaultOwner={createTaskDialog.defaultOwner} defaultStartImmediately={createTaskDialog.defaultStartImmediately} + defaultChip={createTaskDialog.defaultChip} onClose={closeCreateTaskDialog} onSubmit={handleCreateTask} submitting={creatingTask} @@ -1455,6 +1478,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele members={activeMembers} defaultRecipient={sendDialogRecipient} defaultText={sendDialogDefaultText} + defaultChip={sendDialogDefaultChip} quotedMessage={replyQuote} sending={sendingMessage} sendError={sendMessageError} @@ -1479,6 +1503,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setSendDialogOpen(false); setReplyQuote(undefined); setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); }} /> diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index e8de42bc..c15114bd 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -22,11 +22,15 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; +import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { AlertTriangle, Search } from 'lucide-react'; +import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; @@ -40,6 +44,7 @@ interface CreateTaskDialogProps { defaultDescription?: string; defaultOwner?: string; defaultStartImmediately?: boolean; + defaultChip?: InlineChip; onClose: () => void; onSubmit: ( subject: string, @@ -63,6 +68,7 @@ export const CreateTaskDialog = ({ defaultDescription = '', defaultOwner = '', defaultStartImmediately, + defaultChip, onClose, onSubmit, submitting = false, @@ -73,6 +79,7 @@ export const CreateTaskDialog = ({ key: `createTask:${teamName}:description`, initialValue: defaultDescription || undefined, }); + const descChipDraft = useChipDraftPersistence(`createTask:${teamName}:descChips`); const [owner, setOwner] = useState(defaultOwner); const [blockedBy, setBlockedBy] = useState([]); const [related, setRelated] = useState([]); @@ -84,7 +91,11 @@ export const CreateTaskDialog = ({ if (open && !prevOpen) { setSubject(defaultSubject); - if (defaultDescription) { + if (defaultChip) { + const token = chipToken(defaultChip); + descriptionDraft.setValue(token + '\n'); + descChipDraft.setChips([defaultChip]); + } else if (defaultDescription) { descriptionDraft.setValue(defaultDescription); } setOwner(defaultOwner); @@ -130,11 +141,23 @@ export const CreateTaskDialog = ({ ); }; + const handleDescChipRemove = (chipId: string): void => { + const chip = descChipDraft.chips.find((c) => c.id === chipId); + if (chip) { + descriptionDraft.setValue(removeChipTokenFromText(descriptionDraft.value, chip)); + } + descChipDraft.setChips(descChipDraft.chips.filter((c) => c.id !== chipId)); + }; + const handleSubmit = (): void => { if (!canSubmit) return; + const serializedDesc = serializeChipsWithText( + descriptionDraft.value.trim(), + descChipDraft.chips + ); onSubmit( subject.trim(), - descriptionDraft.value.trim(), + serializedDesc, owner || undefined, blockedBy.length > 0 ? blockedBy : undefined, related.length > 0 ? related : undefined, @@ -142,6 +165,7 @@ export const CreateTaskDialog = ({ startImmediately ); descriptionDraft.clearDraft(); + descChipDraft.clearChipDraft(); promptDraft.clearDraft(); }; @@ -239,6 +263,8 @@ export const CreateTaskDialog = ({ value={descriptionDraft.value} onValueChange={descriptionDraft.setValue} suggestions={mentionSuggestions} + chips={descChipDraft.chips} + onChipRemove={handleDescChipRemove} minRows={3} maxRows={12} footerRight={ diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 4a00b710..69206e0c 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -21,12 +21,16 @@ import { } from '@renderer/components/ui/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; +import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { X } from 'lucide-react'; +import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember, SendMessageResult } from '@shared/types'; @@ -41,6 +45,8 @@ interface SendMessageDialogProps { defaultRecipient?: string; /** Pre-filled message text (e.g. from editor selection action) */ defaultText?: string; + /** Pre-filled inline code chip (from editor selection action) */ + defaultChip?: InlineChip; quotedMessage?: QuotedMessage; sending: boolean; sendError: string | null; @@ -56,6 +62,7 @@ export const SendMessageDialog = ({ members, defaultRecipient, defaultText, + defaultChip, quotedMessage, sending, sendError, @@ -67,6 +74,7 @@ export const SendMessageDialog = ({ const [quote, setQuote] = useState(undefined); const [member, setMember] = useState(''); const textDraft = useDraftPersistence({ key: 'sendMessage:text' }); + const chipDraft = useChipDraftPersistence('sendMessage:chips'); const [summary, setSummary] = useState(''); const [prevOpen, setPrevOpen] = useState(false); const [prevResult, setPrevResult] = useState(null); @@ -77,7 +85,11 @@ export const SendMessageDialog = ({ setSummary(''); setQuote(quotedMessage); setPrevResult(lastResult); - if (defaultText) { + if (defaultChip) { + const token = chipToken(defaultChip); + textDraft.setValue(token + '\n'); + chipDraft.setChips([defaultChip]); + } else if (defaultText) { textDraft.setValue(defaultText); } } @@ -98,6 +110,7 @@ export const SendMessageDialog = ({ useEffect(() => { if (pendingAutoClose) { textDraft.clearDraft(); + chipDraft.clearChipDraft(); setPendingAutoClose(false); onClose(); } @@ -121,12 +134,21 @@ export const SendMessageDialog = ({ summary.trim().length > 0 && !sending; + const handleChipRemove = (chipId: string): void => { + const chip = chipDraft.chips.find((c) => c.id === chipId); + if (chip) { + textDraft.setValue(removeChipTokenFromText(textDraft.value, chip)); + } + chipDraft.setChips(chipDraft.chips.filter((c) => c.id !== chipId)); + }; + const handleSubmit = (): void => { if (!canSend) return; - const rawText = textDraft.value.trim(); - const finalText = quote ? buildReplyBlock(quote.from, quote.text, rawText) : rawText; + const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips); + const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized; onSend(member.trim(), finalText, summary.trim()); textDraft.clearDraft(); + chipDraft.clearChipDraft(); }; const handleOpenChange = (nextOpen: boolean): void => { @@ -213,6 +235,8 @@ export const SendMessageDialog = ({ value={textDraft.value} onValueChange={textDraft.setValue} suggestions={mentionSuggestions} + chips={chipDraft.chips} + onChipRemove={handleChipRemove} minRows={4} maxRows={12} footerRight={ diff --git a/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx b/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx new file mode 100644 index 00000000..3b19beea --- /dev/null +++ b/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx @@ -0,0 +1,42 @@ +/** + * Placeholder for non-previewable binary files — shows file info and "Open in System Viewer" button. + */ + +import { Button } from '@renderer/components/ui/button'; +import { useStore } from '@renderer/store'; +import { FileQuestion } from 'lucide-react'; + +interface EditorBinaryPlaceholderProps { + filePath: string; + fileName: string; + size: number; +} + +export const EditorBinaryPlaceholder = ({ + filePath, + fileName, + size, +}: EditorBinaryPlaceholderProps): React.ReactElement => { + const projectPath = useStore((s) => s.editorProjectPath); + const sizeFormatted = + size < 1024 + ? `${size} B` + : size < 1024 * 1024 + ? `${(size / 1024).toFixed(1)} KB` + : `${(size / 1024 / 1024).toFixed(1)} MB`; + + const handleOpenExternal = (): void => { + window.electronAPI.openPath(filePath, projectPath ?? undefined).catch(console.error); + }; + + return ( +
+ +

{fileName}

+

Binary file ({sizeFormatted})

+ +
+ ); +}; diff --git a/src/renderer/components/team/editor/EditorBinaryState.tsx b/src/renderer/components/team/editor/EditorBinaryState.tsx index 0e500640..d239dd11 100644 --- a/src/renderer/components/team/editor/EditorBinaryState.tsx +++ b/src/renderer/components/team/editor/EditorBinaryState.tsx @@ -1,9 +1,12 @@ /** - * Placeholder for binary files — shows file info and "Open in System Viewer" button. + * Router for binary file display — picks the right preview component + * based on file type from the preview registry. */ -import { Button } from '@renderer/components/ui/button'; -import { FileQuestion } from 'lucide-react'; +import { getPreviewType, isPreviewable } from '@renderer/utils/previewRegistry'; + +import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder'; +import { EditorImagePreview } from './EditorImagePreview'; interface EditorBinaryStateProps { filePath: string; @@ -15,25 +18,11 @@ export const EditorBinaryState = ({ size, }: EditorBinaryStateProps): React.ReactElement => { const fileName = filePath.split('/').pop() ?? filePath; - const sizeFormatted = - size < 1024 - ? `${size} B` - : size < 1024 * 1024 - ? `${(size / 1024).toFixed(1)} KB` - : `${(size / 1024 / 1024).toFixed(1)} MB`; + const previewType = getPreviewType(fileName); - const handleOpenExternal = (): void => { - window.electronAPI.openPath(filePath).catch(console.error); - }; + if (previewType === 'image' && isPreviewable(fileName, size)) { + return ; + } - return ( -
- -

{fileName}

-

Binary file ({sizeFormatted})

- -
- ); + return ; }; diff --git a/src/renderer/components/team/editor/EditorBreadcrumb.tsx b/src/renderer/components/team/editor/EditorBreadcrumb.tsx index f8d0ee6e..104994ae 100644 --- a/src/renderer/components/team/editor/EditorBreadcrumb.tsx +++ b/src/renderer/components/team/editor/EditorBreadcrumb.tsx @@ -4,10 +4,11 @@ * Each segment is clickable — expands and scrolls the folder in the file tree. */ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useStore } from '@renderer/store'; import { ChevronRight } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { FileIcon } from './FileIcon'; @@ -16,8 +17,12 @@ import { FileIcon } from './FileIcon'; // ============================================================================= export const EditorBreadcrumb = (): React.ReactElement | null => { - const activeTabId = useStore((s) => s.editorActiveTabId); - const projectPath = useStore((s) => s.editorProjectPath); + const { activeTabId, projectPath } = useStore( + useShallow((s) => ({ + activeTabId: s.editorActiveTabId, + projectPath: s.editorProjectPath, + })) + ); const expandDirectory = useStore((s) => s.expandDirectory); const segments = useMemo(() => { @@ -34,13 +39,15 @@ export const EditorBreadcrumb = (): React.ReactElement | null => { const fileName = segments[segments.length - 1]; - const handleSegmentClick = (segmentIndex: number): void => { - if (!projectPath) return; - // Build absolute path up to this segment (it's a directory) - const dirSegments = segments.slice(0, segmentIndex + 1); - const dirPath = `${projectPath}/${dirSegments.join('/')}`; - void expandDirectory(dirPath); - }; + const handleSegmentClick = useCallback( + (segmentIndex: number): void => { + if (!projectPath) return; + const dirSegments = segments.slice(0, segmentIndex + 1); + const dirPath = `${projectPath}/${dirSegments.join('/')}`; + void expandDirectory(dirPath); + }, + [segments, projectPath, expandDirectory] + ); return (
diff --git a/src/renderer/components/team/editor/EditorContextMenu.tsx b/src/renderer/components/team/editor/EditorContextMenu.tsx index bcf19af7..8c956daf 100644 --- a/src/renderer/components/team/editor/EditorContextMenu.tsx +++ b/src/renderer/components/team/editor/EditorContextMenu.tsx @@ -75,7 +75,7 @@ export const EditorContextMenu = ({ return ( -
+
{children}
diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx index e1978ba4..6ed744c5 100644 --- a/src/renderer/components/team/editor/EditorFileTree.tsx +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -74,10 +74,17 @@ const AUTO_EXPAND_DELAY_MS = 500; // Component // ============================================================================= +// Render counter for debugging — tracks how often the tree re-renders +let fileTreeRenderCount = 0; + export const EditorFileTree = ({ selectedFilePath, onFileSelect, }: EditorFileTreeProps): React.ReactElement => { + fileTreeRenderCount++; + if (fileTreeRenderCount % 5 === 0) { + console.debug(`[perf] EditorFileTree render #${fileTreeRenderCount}`); + } // Data selectors — grouped with useShallow to prevent unnecessary re-renders const { fileTree, expandedDirs, loading, error, gitFiles, projectPath } = useStore( useShallow((s) => ({ @@ -130,14 +137,21 @@ export const EditorFileTree = ({ // Convert hierarchical FileTreeEntry[] → TreeNode[] (respects entry.type) const treeNodes = useMemo(() => { if (!fileTree) return []; - return sortTreeNodes(convertEntriesToNodes(fileTree)); + const t0 = performance.now(); + const nodes = sortTreeNodes(convertEntriesToNodes(fileTree)); + const ms = performance.now() - t0; + if (ms > 2) console.debug(`[perf] treeNodes: ${ms.toFixed(1)}ms, nodes=${nodes.length}`); + return nodes; }, [fileTree]); // Flatten tree into visible items list for virtualization // expandedDirs is keyed by absolute path, and node.fullPath = entry.path (absolute) const flatItems = useMemo(() => { + const t0 = performance.now(); const items: FlatTreeItem[] = []; flattenVisible(treeNodes, expandedDirs, items, 0); + const ms = performance.now() - t0; + if (ms > 2) console.debug(`[perf] flatItems: ${ms.toFixed(1)}ms, items=${items.length}`); return items; }, [treeNodes, expandedDirs]); @@ -176,6 +190,7 @@ export const EditorFileTree = ({ // Git status lookup: absolute path → status type const gitStatusMap = useMemo(() => { + const t0 = performance.now(); const map = new Map(); if (!gitFiles.length || !projectPath) return map; for (const file of gitFiles) { @@ -184,6 +199,8 @@ export const EditorFileTree = ({ : `${projectPath}/${file.path}`; map.set(absPath, file.status); } + const ms = performance.now() - t0; + if (ms > 2) console.debug(`[perf] gitStatusMap: ${ms.toFixed(1)}ms, files=${gitFiles.length}`); return map; }, [gitFiles, projectPath]); @@ -553,7 +570,7 @@ const RootDropZone = React.forwardRef< return (
{ + const projectPath = useStore((s) => s.editorProjectPath); + const [dataUrl, setDataUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lightboxOpen, setLightboxOpen] = useState(false); + const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null); + const imgRef = useRef(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + setDataUrl(null); + setDimensions(null); + + window.electronAPI.editor + .readBinaryPreview(filePath) + .then((result) => { + if (cancelled) return; + setDataUrl(`data:${result.mimeType};base64,${result.base64}`); + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [filePath]); + + const handleImageLoad = useCallback(() => { + const img = imgRef.current; + if (img) { + setDimensions({ w: img.naturalWidth, h: img.naturalHeight }); + } + }, []); + + const handleOpenExternal = useCallback((): void => { + window.electronAPI.openPath(filePath, projectPath ?? undefined).catch(console.error); + }, [filePath, projectPath]); + + const sizeFormatted = + size < 1024 + ? `${size} B` + : size < 1024 * 1024 + ? `${(size / 1024).toFixed(1)} KB` + : `${(size / 1024 / 1024).toFixed(1)} MB`; + + if (loading) { + return ( +
+ +

Loading preview…

+
+ ); + } + + if (error || !dataUrl) { + return ; + } + + return ( +
+ + +

+ {fileName} + {dimensions ? ` — ${dimensions.w}×${dimensions.h}` : ''} + {` — ${sizeFormatted}`} +

+ +
+ +
+ + setLightboxOpen(false)} + /> +
+ ); +}; diff --git a/src/renderer/components/team/editor/EditorSearchPanel.tsx b/src/renderer/components/team/editor/EditorSearchPanel.tsx index c392fea2..4af3a3ab 100644 --- a/src/renderer/components/team/editor/EditorSearchPanel.tsx +++ b/src/renderer/components/team/editor/EditorSearchPanel.tsx @@ -6,7 +6,7 @@ * design language across the app. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { @@ -61,35 +61,37 @@ interface SearchToggleButtonProps { children: React.ReactNode; } -const SearchToggleButton = ({ +const SearchToggleButton = React.memo(function SearchToggleButton({ active, onClick, tooltip, shortcut, children, -}: SearchToggleButtonProps) => ( - - - - - - {tooltip} - {shortcut && {shortcut}} - - -); +}: SearchToggleButtonProps) { + return ( + + + + + + {tooltip} + {shortcut && {shortcut}} + + + ); +}); // ============================================================================= // Match counter diff --git a/src/renderer/components/team/editor/EditorStatusBar.tsx b/src/renderer/components/team/editor/EditorStatusBar.tsx index 47751199..7637e234 100644 --- a/src/renderer/components/team/editor/EditorStatusBar.tsx +++ b/src/renderer/components/team/editor/EditorStatusBar.tsx @@ -27,6 +27,7 @@ export const EditorStatusBar = React.memo(function EditorStatusBar({ watcherEnabled: s.editorWatcherEnabled, })) ); + const toggleWatcher = useStore((s) => s.toggleWatcher); return (
@@ -42,14 +43,26 @@ export const EditorStatusBar = React.memo(function EditorStatusBar({ )}
- {watcherEnabled && ( - - - watching - - File watcher active - - )} + + + + + + {watcherEnabled ? 'Disable external change watcher' : 'Watch for external changes'} + + {language} UTF-8 Spaces: 2 diff --git a/src/renderer/components/team/editor/EditorToolbar.tsx b/src/renderer/components/team/editor/EditorToolbar.tsx index 85ee40f5..45e3e4c9 100644 --- a/src/renderer/components/team/editor/EditorToolbar.tsx +++ b/src/renderer/components/team/editor/EditorToolbar.tsx @@ -2,6 +2,8 @@ * Toolbar with Save, Undo, Redo buttons. */ +import React from 'react'; + import { redo, undo } from '@codemirror/commands'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -92,31 +94,33 @@ interface ToolbarButtonProps { active?: boolean; } -const ToolbarButton = ({ +const ToolbarButton = React.memo(function ToolbarButton({ icon, label, shortcut, onClick, disabled = false, active = false, -}: ToolbarButtonProps): React.ReactElement => ( - - - - - - {label} ({shortcut}) - - -); +}: ToolbarButtonProps): React.ReactElement { + return ( + + + + + + {label} ({shortcut}) + + + ); +}); diff --git a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx index 6787f8ec..528ac79b 100644 --- a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +++ b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx @@ -149,13 +149,19 @@ export const ProjectEditorOverlay = ({ setFileContent(null); try { + const t0 = performance.now(); let promise = pendingReads.current.get(filePath); + const wasCached = !!promise; if (!promise) { promise = window.electronAPI.editor.readFile(filePath); pendingReads.current.set(filePath, promise); void promise.finally(() => pendingReads.current.delete(filePath)); } const result = await promise; + const ipcMs = performance.now() - t0; + console.debug( + `[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${filePath.split('/').pop()}` + ); setFileContent(result); // Track baseline mtime for conflict detection diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 4dcdc5cd..9a3134b9 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -7,8 +7,11 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useAttachments } from '@renderer/hooks/useAttachments'; +import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { cn } from '@renderer/lib/utils'; +import { serializeChipsWithText } from '@renderer/types/inlineChip'; +import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -51,6 +54,7 @@ export const MessageComposer = ({ const fileInputRef = useRef(null); const draft = useDraftPersistence({ key: `compose:${teamName}` }); + const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`); const { attachments, error: attachmentError, @@ -88,11 +92,23 @@ export const MessageComposer = ({ // Track whether we initiated a send — clear draft only on confirmed success const pendingSendRef = useRef(false); + const handleChipRemove = useCallback( + (chipId: string) => { + const chip = chipDraft.chips.find((c) => c.id === chipId); + if (chip) { + draft.setValue(removeChipTokenFromText(draft.value, chip)); + } + chipDraft.setChips(chipDraft.chips.filter((c) => c.id !== chipId)); + }, + [chipDraft, draft] + ); + const handleSend = useCallback(() => { if (!canSend) return; pendingSendRef.current = true; - onSend(recipient, trimmed, trimmed, attachments.length > 0 ? attachments : undefined); - }, [canSend, recipient, trimmed, onSend, attachments]); + const serialized = serializeChipsWithText(trimmed, chipDraft.chips); + onSend(recipient, serialized, serialized, attachments.length > 0 ? attachments : undefined); + }, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]); // Clear draft only after send completes successfully (sending: true → false, no error) useEffect(() => { @@ -100,10 +116,12 @@ export const MessageComposer = ({ pendingSendRef.current = false; if (!sendError) { draft.clearDraft(); + chipDraft.clearChipDraft(); clearAttachments(); } } - }, [sending, sendError, draft, clearAttachments]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- clearChipDraft is stable (useCallback with []) + }, [sending, sendError, draft, clearAttachments, chipDraft.clearChipDraft]); const handleKeyDownCapture = useCallback( (e: React.KeyboardEvent) => { @@ -304,6 +322,8 @@ export const MessageComposer = ({ value={draft.value} onValueChange={draft.setValue} suggestions={mentionSuggestions} + chips={chipDraft.chips} + onChipRemove={handleChipRemove} minRows={2} maxRows={6} maxLength={MAX_MESSAGE_LENGTH} diff --git a/src/renderer/components/ui/ChipInteractionLayer.tsx b/src/renderer/components/ui/ChipInteractionLayer.tsx new file mode 100644 index 00000000..18cd209b --- /dev/null +++ b/src/renderer/components/ui/ChipInteractionLayer.tsx @@ -0,0 +1,178 @@ +/** + * Interactive overlay layer (z-20) for inline code chips. + * + * Positioned above the textarea, provides: + * - Hover tooltip with code preview (first ~12 lines, CodeMirror syntax highlighting) + * - X button to remove a chip + * + * Uses mirror div technique (calculateChipPositions) to position elements + * exactly over the corresponding chip tokens in the textarea. + */ + +import * as React from 'react'; + +import { syntaxHighlighting } from '@codemirror/language'; +import { EditorState } from '@codemirror/state'; +import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; +import { EditorView } from '@codemirror/view'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { chipDisplayLabel } from '@renderer/types/inlineChip'; +import { calculateChipPositions } from '@renderer/utils/chipUtils'; +import { getSyncLanguageExtension } from '@renderer/utils/codemirrorLanguages'; +import { X } from 'lucide-react'; + +import type { InlineChip } from '@renderer/types/inlineChip'; +import type { ChipPosition } from '@renderer/utils/chipUtils'; + +// ============================================================================= +// Compact read-only CodeMirror theme for tooltip preview +// ============================================================================= + +const chipPreviewTheme = EditorView.theme({ + '&': { + fontSize: '11px', + backgroundColor: 'var(--code-bg, #1e1e2e)', + color: 'var(--color-text)', + }, + '.cm-content': { + padding: '8px 10px', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace', + lineHeight: '1.6', + }, + '&.cm-focused': { outline: 'none' }, + '.cm-scroller': { overflow: 'auto' }, + '.cm-gutters': { display: 'none' }, + '.cm-activeLine': { backgroundColor: 'transparent' }, + '.cm-selectionBackground': { backgroundColor: 'transparent' }, + '.cm-cursor': { display: 'none' }, +}); + +// ============================================================================= +// Code preview subcomponent (CodeMirror read-only) +// ============================================================================= + +const MAX_PREVIEW_LINES = 12; + +const ChipCodePreview = ({ chip }: { chip: InlineChip }): React.JSX.Element => { + const containerRef = React.useRef(null); + const allLines = chip.codeText.split('\n'); + const truncated = allLines.length > MAX_PREVIEW_LINES; + const visibleCode = truncated ? allLines.slice(0, MAX_PREVIEW_LINES).join('\n') : chip.codeText; + const label = chipDisplayLabel(chip); + const lineRef = + chip.fromLine === chip.toLine + ? `line ${chip.fromLine}` + : `lines ${chip.fromLine}-${chip.toLine}`; + + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const langExt = getSyncLanguageExtension(chip.fileName); + + const state = EditorState.create({ + doc: visibleCode, + extensions: [ + chipPreviewTheme, + syntaxHighlighting(oneDarkHighlightStyle), + EditorView.editable.of(false), + EditorState.readOnly.of(true), + ...(langExt ? [langExt] : []), + ], + }); + + const view = new EditorView({ state, parent: container }); + + return () => { + view.destroy(); + }; + }, [visibleCode, chip.fileName]); + + return ( +
+
+ {label} + {lineRef} +
+
+ {truncated ? ( +
+ + ({allLines.length - MAX_PREVIEW_LINES} more lines...) + +
+ ) : null} +
+ ); +}; + +// ============================================================================= +// Interaction layer +// ============================================================================= + +interface ChipInteractionLayerProps { + chips: InlineChip[]; + value: string; + textareaRef: React.RefObject; + scrollTop: number; + onRemove: (chipId: string) => void; +} + +export const ChipInteractionLayer = ({ + chips, + value, + textareaRef, + scrollTop, + onRemove, +}: ChipInteractionLayerProps): React.JSX.Element | null => { + const [positions, setPositions] = React.useState([]); + + React.useLayoutEffect(() => { + if (chips.length === 0) { + setPositions([]); + return; + } + const textarea = textareaRef.current; + if (!textarea) return; + setPositions(calculateChipPositions(textarea, value, chips)); + }, [chips, value, textareaRef]); + + if (positions.length === 0) return null; + + return ( +
+
+ {positions.map((pos) => ( + + +
+ +
+
+ + + +
+ ))} +
+
+ ); +}; diff --git a/src/renderer/components/ui/CodeChipBadge.tsx b/src/renderer/components/ui/CodeChipBadge.tsx new file mode 100644 index 00000000..8ef4e6ef --- /dev/null +++ b/src/renderer/components/ui/CodeChipBadge.tsx @@ -0,0 +1,32 @@ +/** + * Styled span for rendering inline code chip tokens in the backdrop overlay. + * Uses the same text as the textarea (transparent) to maintain pixel-perfect alignment. + * + * Purple color scheme to distinguish from @mention badges (blue). + */ + +import type { InlineChip } from '@renderer/types/inlineChip'; + +const CHIP_BG = 'rgba(139, 92, 246, 0.15)'; +const CHIP_TEXT = '#a78bfa'; + +interface CodeChipBadgeProps { + chip: InlineChip; + /** The full chip token text (e.g. "📄auth.ts:10-15") */ + tokenText: string; +} + +export const CodeChipBadge = ({ tokenText }: CodeChipBadgeProps): React.JSX.Element => { + return ( + + {tokenText} + + ); +}; diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index 5414bb56..d6b59039 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -3,15 +3,24 @@ import * as React from 'react'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useMentionDetection } from '@renderer/hooks/useMentionDetection'; import { cn } from '@renderer/lib/utils'; +import { chipToken } from '@renderer/types/inlineChip'; +import { + findChipBoundary, + reconcileChips, + removeChipTokenFromText, +} from '@renderer/utils/chipUtils'; import { AutoResizeTextarea } from './auto-resize-textarea'; +import { ChipInteractionLayer } from './ChipInteractionLayer'; +import { CodeChipBadge } from './CodeChipBadge'; import { MentionSuggestionList } from './MentionSuggestionList'; import type { AutoResizeTextareaProps } from './auto-resize-textarea'; +import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; // --------------------------------------------------------------------------- -// Mention segment parsing (splits text into plain text + @mention segments) +// Segment types // --------------------------------------------------------------------------- interface TextSegment { @@ -25,7 +34,17 @@ interface MentionSegment { suggestion: MentionSuggestion; } -type Segment = TextSegment | MentionSegment; +interface ChipSegment { + type: 'chip'; + value: string; + chip: InlineChip; +} + +type Segment = TextSegment | MentionSegment | ChipSegment; + +// --------------------------------------------------------------------------- +// Mention segment parsing (splits text into plain text + @mention segments) +// --------------------------------------------------------------------------- /** * Splits text into alternating text / @mention segments. @@ -96,6 +115,64 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S return segments; } +// --------------------------------------------------------------------------- +// Extended segment parser: chips + mentions +// --------------------------------------------------------------------------- + +/** + * Parses text into segments: first extracts chip tokens, then runs mention parsing + * on the text fragments between chips. + */ +function parseSegments( + text: string, + suggestions: MentionSuggestion[], + chips: InlineChip[] +): Segment[] { + if (!text) return [{ type: 'text', value: text }]; + if (chips.length === 0) return parseMentionSegments(text, suggestions); + + // Build a map of chip tokens for fast lookup + const chipTokenMap = new Map(); + for (const chip of chips) { + chipTokenMap.set(chipToken(chip), chip); + } + + // Find all chip token positions, sorted by index + const chipPositions: { start: number; end: number; token: string; chip: InlineChip }[] = []; + for (const [token, chip] of chipTokenMap) { + let searchFrom = 0; + while (searchFrom < text.length) { + const idx = text.indexOf(token, searchFrom); + if (idx === -1) break; + chipPositions.push({ start: idx, end: idx + token.length, token, chip }); + searchFrom = idx + 1; + } + } + chipPositions.sort((a, b) => a.start - b.start); + + if (chipPositions.length === 0) return parseMentionSegments(text, suggestions); + + const segments: Segment[] = []; + let lastEnd = 0; + + for (const pos of chipPositions) { + // Text before this chip → parse for mentions + if (pos.start > lastEnd) { + const fragment = text.slice(lastEnd, pos.start); + segments.push(...parseMentionSegments(fragment, suggestions)); + } + segments.push({ type: 'chip', value: pos.token, chip: pos.chip }); + lastEnd = pos.end; + } + + // Remaining text after last chip → parse for mentions + if (lastEnd < text.length) { + segments.push(...parseMentionSegments(text.slice(lastEnd), suggestions)); + } + + return segments; +} + // Default fallback color for mentions without a team color const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)'; const DEFAULT_MENTION_TEXT = '#60a5fa'; @@ -117,6 +194,10 @@ interface MentionableTextareaProps extends Omit< footerRight?: React.ReactNode; /** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */ cornerAction?: React.ReactNode; + /** Inline code chips to display as badges */ + chips?: InlineChip[]; + /** Called when a chip is removed (by X button, backspace, or reconciliation) */ + onChipRemove?: (chipId: string) => void; } export const MentionableTextarea = React.forwardRef( @@ -129,6 +210,8 @@ export const MentionableTextarea = React.forwardRef { const internalRef = React.useRef(null); const backdropRef = React.useRef(null); + const [scrollTop, setScrollTop] = React.useState(0); const setRefs = React.useCallback( (node: HTMLTextAreaElement | null) => { @@ -158,9 +242,9 @@ export const MentionableTextarea = React.forwardRef vs
, and sub-pixel differences accumulate over text length. React.useLayoutEffect(() => { const textarea = internalRef.current; const backdrop = backdropRef.current; @@ -182,27 +264,174 @@ export const MentionableTextarea = React.forwardRef 0; + // --- Overlay activation --- + const hasOverlay = suggestions.length > 0 || chips.length > 0; const segments = React.useMemo( - () => (hasMentionOverlay ? parseMentionSegments(value, suggestions) : []), - [hasMentionOverlay, value, suggestions] + () => (hasOverlay ? parseSegments(value, suggestions, chips) : []), + [hasOverlay, value, suggestions, chips] ); - // Sync backdrop scroll with textarea scroll + // Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer const handleScroll = React.useCallback(() => { const textarea = internalRef.current; const backdrop = backdropRef.current; - if (textarea && backdrop) { - backdrop.scrollTop = textarea.scrollTop; + if (textarea) { + if (backdrop) { + backdrop.scrollTop = textarea.scrollTop; + } + setScrollTop(textarea.scrollTop); } }, []); + // --- Chip keyboard handling (atomic cursor / backspace / delete) --- + const handleChipKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (chips.length === 0 || !onChipRemove) return; + + const textarea = internalRef.current; + if (!textarea) return; + + const { selectionStart, selectionEnd } = textarea; + // Only act on collapsed cursor + if (selectionStart !== selectionEnd && !e.shiftKey) return; + + const cursorPos = selectionStart; + + if (e.key === 'Backspace') { + // If cursor is at chip end → delete entire chip + const boundary = findChipBoundary(value, chips, cursorPos); + if (cursorPos === boundary?.end) { + e.preventDefault(); + const newText = removeChipTokenFromText(value, boundary.chip); + onValueChange(newText); + onChipRemove(boundary.chip.id); + // Set cursor to where chip started + requestAnimationFrame(() => { + textarea.setSelectionRange(boundary.start, boundary.start); + }); + } + } else if (e.key === 'Delete') { + // If cursor is at chip start → delete entire chip + const boundary = findChipBoundary(value, chips, cursorPos); + if (cursorPos === boundary?.start) { + e.preventDefault(); + const newText = removeChipTokenFromText(value, boundary.chip); + onValueChange(newText); + onChipRemove(boundary.chip.id); + requestAnimationFrame(() => { + textarea.setSelectionRange(boundary.start, boundary.start); + }); + } + } else if (e.key === 'ArrowLeft' && !e.shiftKey) { + // If cursor is at chip end → jump to chip start + const boundary = findChipBoundary(value, chips, cursorPos); + if (cursorPos === boundary?.end) { + e.preventDefault(); + textarea.setSelectionRange(boundary.start, boundary.start); + } + } else if (e.key === 'ArrowRight' && !e.shiftKey) { + // If cursor is at chip start → jump to chip end + const boundary = findChipBoundary(value, chips, cursorPos); + if (cursorPos === boundary?.start) { + e.preventDefault(); + textarea.setSelectionRange(boundary.end, boundary.end); + } + } else if (e.key === 'ArrowLeft' && e.shiftKey) { + // Extend selection past chip atomically + const boundary = findChipBoundary(value, chips, cursorPos); + if (cursorPos === boundary?.end) { + e.preventDefault(); + textarea.setSelectionRange(boundary.start, selectionEnd); + } + } else if (e.key === 'ArrowRight' && e.shiftKey) { + const boundary = findChipBoundary(value, chips, cursorPos); + if (cursorPos === boundary?.start) { + e.preventDefault(); + textarea.setSelectionRange(selectionStart, boundary.end); + } + } + }, + [chips, onChipRemove, value, onValueChange] + ); + + // Composed key handler: chip logic → mention logic + const composedHandleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + handleChipKeyDown(e); + if (!e.defaultPrevented) { + mentionHandleKeyDown(e); + } + }, + [handleChipKeyDown, mentionHandleKeyDown] + ); + + // --- Chip reconciliation on text change --- + const composedHandleChange = React.useCallback( + (e: React.ChangeEvent) => { + mentionHandleChange(e); + + // Reconcile chips after text changes (paste/cut/undo) + if (chips.length > 0 && onChipRemove) { + const newText = e.target.value; + const surviving = reconcileChips(chips, newText); + if (surviving.length < chips.length) { + const survivingIds = new Set(surviving.map((c) => c.id)); + for (const chip of chips) { + if (!survivingIds.has(chip.id)) { + onChipRemove(chip.id); + } + } + } + } + }, + [mentionHandleChange, chips, onChipRemove] + ); + + // --- Snap cursor on click/select if inside chip --- + const composedHandleSelect = React.useCallback( + (e: React.SyntheticEvent) => { + mentionHandleSelect(e); + + if (chips.length > 0) { + const textarea = internalRef.current; + if (!textarea) return; + const { selectionStart, selectionEnd } = textarea; + // Only snap collapsed cursor + if (selectionStart !== selectionEnd) return; + + const boundary = findChipBoundary(value, chips, selectionStart); + if (boundary && selectionStart > boundary.start && selectionStart < boundary.end) { + // Snap to nearest boundary + const distToStart = selectionStart - boundary.start; + const distToEnd = boundary.end - selectionStart; + const snapTo = distToStart <= distToEnd ? boundary.start : boundary.end; + requestAnimationFrame(() => { + textarea.setSelectionRange(snapTo, snapTo); + }); + } + } + }, + [mentionHandleSelect, chips, value] + ); + + // --- Chip remove handler (from X button in interaction layer) --- + const handleChipRemove = React.useCallback( + (chipId: string) => { + const chip = chips.find((c) => c.id === chipId); + if (chip) { + const newText = removeChipTokenFromText(value, chip); + onValueChange(newText); + } + onChipRemove?.(chipId); + }, + [chips, value, onValueChange, onChipRemove] + ); + // When overlay is active: textarea text is transparent, caret stays visible - const textareaStyle: React.CSSProperties | undefined = hasMentionOverlay + const textareaStyle: React.CSSProperties | undefined = hasOverlay ? { ...style, color: 'transparent', @@ -219,7 +448,7 @@ export const MentionableTextarea = React.forwardRef {/* Inner wrapper for textarea + backdrop overlay */}
- {hasMentionOverlay ? ( + {hasOverlay ? (
{seg.value}; } + if (seg.type === 'chip') { + return ; + } + // mention const colorSet = seg.suggestion.color ? getTeamColorSet(seg.suggestion.color) : null; @@ -262,14 +495,25 @@ export const MentionableTextarea = React.forwardRef + + {chips.length > 0 && onChipRemove ? ( + + ) : null} + {cornerAction ? (
{cornerAction}
diff --git a/src/renderer/hooks/useChipDraftPersistence.ts b/src/renderer/hooks/useChipDraftPersistence.ts new file mode 100644 index 00000000..f6e155f6 --- /dev/null +++ b/src/renderer/hooks/useChipDraftPersistence.ts @@ -0,0 +1,129 @@ +/** + * Draft persistence for InlineChip arrays. + * + * Uses the same draftStorage (IndexedDB + fallback) as useDraftPersistence, + * serializing chips as JSON strings. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { draftStorage } from '@renderer/services/draftStorage'; + +import type { InlineChip } from '@renderer/types/inlineChip'; + +interface UseChipDraftResult { + chips: InlineChip[]; + /** Accepts a direct value (not a callback). Saves to draftStorage with debounce. */ + setChips: (chips: InlineChip[]) => void; + clearChipDraft: () => void; + isSaved: boolean; +} + +const DEBOUNCE_MS = 500; + +function isValidChipArray(data: unknown): data is InlineChip[] { + if (!Array.isArray(data)) return false; + return data.every( + (item) => + typeof item === 'object' && + item !== null && + typeof item.id === 'string' && + typeof item.filePath === 'string' && + typeof item.fileName === 'string' && + typeof item.fromLine === 'number' && + typeof item.toLine === 'number' && + typeof item.codeText === 'string' && + typeof item.language === 'string' + ); +} + +export function useChipDraftPersistence(key: string): UseChipDraftResult { + const [chips, setChipsState] = useState([]); + const [isSaved, setIsSaved] = useState(false); + const timerRef = useRef | null>(null); + const pendingRef = useRef(null); + const keyRef = useRef(key); + keyRef.current = key; + + // Load on mount + useEffect(() => { + let cancelled = false; + void (async () => { + const raw = await draftStorage.loadDraft(key); + if (cancelled || raw == null) return; + try { + const parsed: unknown = JSON.parse(raw); + if (isValidChipArray(parsed)) { + setChipsState(parsed); + setIsSaved(true); + } + } catch { + // Invalid JSON — ignore, start with empty array + } + })(); + return () => { + cancelled = true; + }; + }, [key]); + + const flushPending = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (pendingRef.current != null) { + const val = pendingRef.current; + pendingRef.current = null; + if (val.length === 0) { + void draftStorage.deleteDraft(keyRef.current); + } else { + void draftStorage.saveDraft(keyRef.current, JSON.stringify(val)); + } + } + }, []); + + // Flush on unmount + useEffect(() => { + return () => { + flushPending(); + }; + }, [flushPending]); + + const setChips = useCallback((nextChips: InlineChip[]) => { + setChipsState(nextChips); + setIsSaved(false); + pendingRef.current = nextChips; + + if (timerRef.current != null) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + timerRef.current = null; + const pending = pendingRef.current; + pendingRef.current = null; + if (pending == null) return; + + if (pending.length === 0) { + void draftStorage.deleteDraft(keyRef.current); + } else { + void draftStorage.saveDraft(keyRef.current, JSON.stringify(pending)).then(() => { + setIsSaved(true); + }); + } + }, DEBOUNCE_MS); + }, []); + + const clearChipDraft = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + pendingRef.current = null; + setChipsState([]); + setIsSaved(false); + void draftStorage.deleteDraft(keyRef.current); + }, []); + + return { chips, setChips, clearChipDraft, isSaved }; +} diff --git a/src/renderer/index.css b/src/renderer/index.css index 866a2da2..c3d7d5d5 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -542,6 +542,15 @@ body { scrollbar-color: var(--scrollbar-thumb) transparent; } +/* Thin scrollbar for narrow panels (sidebar, search) */ +.scrollbar-thin::-webkit-scrollbar { + width: 6px; +} + +.scrollbar-thin::-webkit-scrollbar-thumb { + border-radius: 3px; +} + /* Dashboard skeleton shimmer animation */ @keyframes shimmer { 0% { @@ -653,3 +662,28 @@ body { .label-optional { color: var(--color-text-muted); } + +/* Checkerboard background for transparent image previews */ +.checkerboard-bg { + background-image: + linear-gradient(45deg, #1a1a2e 25%, transparent 25%), + linear-gradient(-45deg, #1a1a2e 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #1a1a2e 75%), + linear-gradient(-45deg, transparent 75%, #1a1a2e 75%); + background-size: 16px 16px; + background-position: + 0 0, + 0 8px, + 8px -8px, + -8px 0; + background-color: #12131a; +} + +:root.light .checkerboard-bg { + background-image: + linear-gradient(45deg, #e2e8f0 25%, transparent 25%), + linear-gradient(-45deg, #e2e8f0 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e2e8f0 75%), + linear-gradient(-45deg, transparent 75%, #e2e8f0 75%); + background-color: #ffffff; +} diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts index 394ee14d..3b349e6f 100644 --- a/src/renderer/store/slices/editorSlice.ts +++ b/src/renderer/store/slices/editorSlice.ts @@ -25,8 +25,9 @@ import type { StateCreator } from 'zustand'; const log = createLogger('Store:editor'); -/** Remove a key from a record without triggering unused-variable linting. */ +/** Remove a key from a record. Returns the same reference if key doesn't exist. */ function omitKey(record: Record, key: string): Record { + if (!(key in record)) return record; const result = { ...record }; delete result[key]; return result; @@ -44,6 +45,32 @@ function omitKey(record: Record, key: string): Record { const recentSaveTimestamps = new Map(); const SAVE_COOLDOWN_MS = 2000; +/** + * Throttle timers for watcher-driven updates. + * Keeping these module-level avoids store re-renders during bursts. + */ +let gitStatusThrottleTimer: ReturnType | null = null; +const GIT_STATUS_THROTTLE_MS = 1500; +const gitStatusChangeDebounceTimer: ReturnType | null = null; +const GIT_STATUS_CHANGE_DEBOUNCE_MS = 6000; +const dirRefreshDebounceTimers = new Map>(); +const DIR_REFRESH_DEBOUNCE_MS = 350; + +// Watcher event logging can be extremely expensive during bursts. +// Keep a lightweight aggregate counter instead of logging per event. +let watcherEventLogTimer: ReturnType | null = null; +let watcherEventCounts: Record = { + change: 0, + create: 0, + delete: 0, +}; + +/** + * Open request sequence for editor initialization. + * Cancels stale async work (notably React 18 StrictMode dev effect mount/unmount). + */ +let editorOpenSeq = 0; + /** * Cooldown map: filePath → timestamp of last successful move. * Suppresses watcher events triggered by our own move operations. @@ -51,6 +78,22 @@ const SAVE_COOLDOWN_MS = 2000; const recentMoveTimestamps = new Map(); const MOVE_COOLDOWN_MS = 2000; +function scheduleIdleWork(cb: () => void): void { + // Prefer requestIdleCallback when available; fall back to a short timeout. + // This keeps editor open responsive for large repos. + try { + const ric = (window as unknown as { requestIdleCallback?: (fn: () => void) => number }) + .requestIdleCallback; + if (typeof ric === 'function') { + ric(cb); + return; + } + } catch { + // ignore + } + setTimeout(cb, 150); +} + // ============================================================================= // Slice Interface // ============================================================================= @@ -196,6 +239,7 @@ export const createEditorSlice: StateCreator = (s // ═══════════════════════════════════════════════════════ openEditor: async (projectPath: string) => { + const openSeq = ++editorOpenSeq; set({ editorProjectPath: projectPath, editorFileTree: null, @@ -222,20 +266,60 @@ export const createEditorSlice: StateCreator = (s }); try { + const tOpen = performance.now(); await api.editor.open(projectPath); + const openMs = performance.now() - tOpen; - // Parallelize: readDir + git status + watcher setup run concurrently - const [result] = await Promise.all([ - api.editor.readDir(projectPath), - get().fetchGitStatus(), - get().toggleWatcher(true), - ]); + // Cancel stale opens (e.g. StrictMode effect cleanup, or rapid project switching) + if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) { + return; + } + // Load file tree first so UI becomes interactive quickly. + // Git status and file watching can be expensive on large projects, so they are NOT awaited here. + const tReadDir = performance.now(); + const result = await api.editor.readDir(projectPath); + const readDirMs = performance.now() - tReadDir; + + if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) { + return; + } + + const tSet = performance.now(); set({ editorFileTree: result.entries, editorFileTreeLoading: false, }); + const setMs = performance.now() - tSet; + + log.info( + `[perf] openEditor: open=${openMs.toFixed(1)}ms, readDir=${readDirMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, entries=${result.entries.length}` + ); + + // Enable watcher by default (like most editors), but defer startup until idle so open stays fast. + // Allow users to persistently disable it via localStorage toggle. + const watcherDesired = (() => { + try { + return localStorage.getItem('editor-watcher-enabled') !== 'false'; + } catch { + return true; + } + })(); + + scheduleIdleWork(() => { + if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return; + if (watcherDesired) void get().toggleWatcher(true); + // Defer initial git status a bit more — it can be expensive on large repos. + setTimeout(() => { + if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return; + void get().fetchGitStatus(); + }, 1200); + }); } catch (error) { + // Ignore errors from stale opens (e.g. StrictMode cleanup during dev) + if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) { + return; + } const message = error instanceof Error ? error.message : String(error); log.error('Failed to open editor:', message); set({ @@ -246,6 +330,8 @@ export const createEditorSlice: StateCreator = (s }, closeEditor: () => { + // Cancel any in-flight openEditor async work + editorOpenSeq++; // Clear cooldown timestamps (no stale entries across editor sessions) recentSaveTimestamps.clear(); recentMoveTimestamps.clear(); @@ -288,11 +374,18 @@ export const createEditorSlice: StateCreator = (s set({ editorFileTreeLoading: true, editorFileTreeError: null }); try { + const t0 = performance.now(); const result = await api.editor.readDir(dirPath); + const ipcMs = performance.now() - t0; + const t1 = performance.now(); set({ editorFileTree: result.entries, editorFileTreeLoading: false, }); + const setMs = performance.now() - t1; + log.info( + `[perf] loadFileTree: IPC=${ipcMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, entries=${result.entries.length}` + ); } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('Failed to load file tree:', message); @@ -306,15 +399,29 @@ export const createEditorSlice: StateCreator = (s expandDirectory: async (dirPath: string) => { const { editorExpandedDirs, editorFileTree } = get(); - // Mark as expanded immediately for responsive UI - set({ - editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true }, - }); + // Skip set() if already expanded — prevents unnecessary re-render + const wasExpanded = !!editorExpandedDirs[dirPath]; + if (!wasExpanded) { + set({ + editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true }, + }); + } try { + const t0 = performance.now(); const result = await api.editor.readDir(dirPath); - const updatedTree = mergeChildrenIntoTree(editorFileTree ?? [], dirPath, result.entries); + const ipcMs = performance.now() - t0; + // Use fresh tree from store after await to avoid overwriting concurrent updates + const currentTree = get().editorFileTree; + const t1 = performance.now(); + const updatedTree = mergeChildrenIntoTree(currentTree ?? [], dirPath, result.entries); + const mergeMs = performance.now() - t1; + const t2 = performance.now(); set({ editorFileTree: updatedTree }); + const setMs = performance.now() - t2; + log.info( + `[perf] expandDirectory: IPC=${ipcMs.toFixed(1)}ms, merge=${mergeMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, entries=${result.entries.length}, wasExpanded=${wasExpanded}` + ); } catch (error) { const message = error instanceof Error ? error.message : String(error); log.error('Failed to expand directory:', message); @@ -856,13 +963,20 @@ export const createEditorSlice: StateCreator = (s fetchGitStatus: async () => { set({ editorGitLoading: true }); try { + const t0 = performance.now(); const result = await api.editor.gitStatus(); + const ipcMs = performance.now() - t0; + const t1 = performance.now(); set({ editorGitFiles: result.files, editorGitBranch: result.branch, editorIsGitRepo: result.isGitRepo, editorGitLoading: false, }); + const setMs = performance.now() - t1; + log.info( + `[perf] fetchGitStatus: IPC=${ipcMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, files=${result.files.length}` + ); } catch (error) { log.error('Failed to fetch git status:', error); set({ editorGitLoading: false }); @@ -873,6 +987,11 @@ export const createEditorSlice: StateCreator = (s try { await api.editor.watchDir(enable); set({ editorWatcherEnabled: enable }); + try { + localStorage.setItem('editor-watcher-enabled', String(enable)); + } catch { + // localStorage may not be available + } } catch (error) { log.error('Failed to toggle watcher:', error); } @@ -891,6 +1010,19 @@ export const createEditorSlice: StateCreator = (s }, handleExternalFileChange: (event: EditorFileChangeEvent) => { + // Avoid per-event logging (can freeze renderer during bursts on large repos). + watcherEventCounts[event.type] = (watcherEventCounts[event.type] ?? 0) + 1; + if (!watcherEventLogTimer) { + watcherEventLogTimer = setTimeout(() => { + watcherEventLogTimer = null; + const counts = watcherEventCounts; + watcherEventCounts = { change: 0, create: 0, delete: 0 }; + // Keep a single lightweight summary line. + log.info( + `[perf] editor watcher events (2s): change=${counts.change}, create=${counts.create}, delete=${counts.delete}` + ); + }, 2000); + } const { editorOpenTabs, editorProjectPath, editorSaving } = get(); // Ignore watcher events for files we are currently saving (our own write) @@ -916,15 +1048,27 @@ export const createEditorSlice: StateCreator = (s })); } - // Refresh git status on any change - void get().fetchGitStatus(); + // Refresh git status on change — throttled to avoid expensive work during bursts. + // Main process already caches git status for 5s, but IPC + store updates still cost. + if (!gitStatusThrottleTimer) { + gitStatusThrottleTimer = setTimeout(() => { + gitStatusThrottleTimer = null; + void get().fetchGitStatus(); + }, GIT_STATUS_THROTTLE_MS); + } // Refresh parent directory in tree for create/delete if (event.type === 'create' || event.type === 'delete') { invalidateQuickOpenCache(); const parentDir = event.path.substring(0, event.path.lastIndexOf('/')); if (parentDir && editorProjectPath) { - void refreshDirectory(get, set, parentDir); + const existing = dirRefreshDebounceTimers.get(parentDir); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + dirRefreshDebounceTimers.delete(parentDir); + void refreshDirectory(get, set, parentDir); + }, DIR_REFRESH_DEBOUNCE_MS); + dirRefreshDebounceTimers.set(parentDir, timer); } } }, @@ -1000,7 +1144,11 @@ async function refreshDirectory( dirPath: string ): Promise { try { + const t0 = performance.now(); const result = await api.editor.readDir(dirPath); + log.info( + `[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${dirPath.split('/').pop()}` + ); const currentTree = get().editorFileTree; if (!currentTree) return; @@ -1074,22 +1222,27 @@ function remapRecord( /** * Recursively merge children into the tree at the matching directory path. + * Returns the same array reference if nothing changed — preserves React.memo equality. */ function mergeChildrenIntoTree( tree: FileTreeEntry[], targetPath: string, children: FileTreeEntry[] ): FileTreeEntry[] { - return tree.map((entry) => { + let changed = false; + const result = tree.map((entry) => { if (entry.path === targetPath && entry.type === 'directory') { + changed = true; return { ...entry, children }; } if (entry.children) { - return { - ...entry, - children: mergeChildrenIntoTree(entry.children, targetPath, children), - }; + const updated = mergeChildrenIntoTree(entry.children, targetPath, children); + if (updated !== entry.children) { + changed = true; + return { ...entry, children: updated }; + } } return entry; }); + return changed ? result : tree; } diff --git a/src/renderer/types/inlineChip.ts b/src/renderer/types/inlineChip.ts new file mode 100644 index 00000000..84836156 --- /dev/null +++ b/src/renderer/types/inlineChip.ts @@ -0,0 +1,81 @@ +/** + * Inline Code Chip types and pure functions. + * + * A chip is a visual badge representing a code selection from the editor, + * displayed inline in textareas alongside @mentions. + */ + +import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface InlineChip { + id: string; + /** Absolute file path */ + filePath: string; + /** Basename (e.g. "auth.ts") */ + fileName: string; + /** 1-based start line */ + fromLine: number; + /** 1-based end line */ + toLine: number; + /** Selected source code text */ + codeText: string; + /** Language identifier (e.g. "typescript", "python") */ + language: string; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** Unicode marker character used as chip prefix in textarea text */ +export const CHIP_MARKER = '\u{1F4C4}'; // 📄 + +// ============================================================================= +// Pure functions +// ============================================================================= + +/** + * Display label for a chip: "auth.ts:10-15" or "auth.ts:42" for single-line. + */ +export function chipDisplayLabel(chip: InlineChip): string { + if (chip.fromLine === chip.toLine) { + return `${chip.fileName}:${chip.fromLine}`; + } + return `${chip.fileName}:${chip.fromLine}-${chip.toLine}`; +} + +/** + * Token string inserted into textarea text. + * Must match EXACTLY in textarea and overlay for pixel-perfect alignment. + */ +export function chipToken(chip: InlineChip): string { + return `${CHIP_MARKER}${chipDisplayLabel(chip)}`; +} + +/** + * Converts a chip to a markdown code fence block. + */ +export function chipToMarkdown(chip: InlineChip): string { + const label = chipDisplayLabel(chip); + const lang = chip.language || getCodeFenceLanguage(chip.fileName); + return `**${chip.fileName}** (${chip.fromLine === chip.toLine ? `line ${chip.fromLine}` : `lines ${chip.fromLine}-${chip.toLine}`}):\n\`\`\`${lang}\n${chip.codeText}\n\`\`\``; +} + +/** + * Serializes text with chip tokens back to markdown code fences for sending. + * Replaces each chip token in the text with its markdown representation. + */ +export function serializeChipsWithText(text: string, chips: InlineChip[]): string { + if (chips.length === 0) return text; + + let result = text; + for (const chip of chips) { + const token = chipToken(chip); + result = result.split(token).join(chipToMarkdown(chip)); + } + return result; +} diff --git a/src/renderer/utils/chipUtils.ts b/src/renderer/utils/chipUtils.ts new file mode 100644 index 00000000..ec3e69cd --- /dev/null +++ b/src/renderer/utils/chipUtils.ts @@ -0,0 +1,245 @@ +/** + * Utility functions for working with inline code chip tokens in text. + */ + +import { chipToken } from '@renderer/types/inlineChip'; +import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction'; + +import type { InlineChip } from '@renderer/types/inlineChip'; +import type { EditorSelectionAction } from '@shared/types/editor'; + +// ============================================================================= +// Chip creation +// ============================================================================= + +let chipCounter = 0; + +/** + * Creates an InlineChip from an EditorSelectionAction. + * Returns null if a chip with the same filePath + line range already exists. + */ +export function createChipFromSelection( + action: EditorSelectionAction, + existingChips: InlineChip[] +): InlineChip | null { + const isDuplicate = existingChips.some( + (c) => + c.filePath === action.filePath && c.fromLine === action.fromLine && c.toLine === action.toLine + ); + if (isDuplicate) return null; + + const fileName = action.filePath.split('/').pop() ?? 'file'; + const language = getCodeFenceLanguage(fileName); + + return { + id: `chip-${++chipCounter}-${Date.now()}`, + filePath: action.filePath, + fileName, + fromLine: action.fromLine, + toLine: action.toLine, + codeText: action.selectedText, + language, + }; +} + +// ============================================================================= +// Chip boundary detection +// ============================================================================= + +export interface ChipBoundary { + start: number; + end: number; + chip: InlineChip; +} + +/** + * Finds the chip token boundary that contains or is adjacent to the cursor position. + * Returns null if cursor is not at/inside any chip token. + */ +export function findChipBoundary( + text: string, + chips: InlineChip[], + cursorPos: number +): ChipBoundary | null { + for (const chip of chips) { + const token = chipToken(chip); + let searchFrom = 0; + while (searchFrom < text.length) { + const idx = text.indexOf(token, searchFrom); + if (idx === -1) break; + const end = idx + token.length; + if (cursorPos >= idx && cursorPos <= end) { + return { start: idx, end, chip }; + } + searchFrom = idx + 1; + } + } + return null; +} + +/** + * Returns true if cursor is strictly inside a chip token (not at boundaries). + */ +export function isInsideChip(text: string, chips: InlineChip[], cursorPos: number): boolean { + const boundary = findChipBoundary(text, chips, cursorPos); + if (!boundary) return false; + return cursorPos > boundary.start && cursorPos < boundary.end; +} + +/** + * Snaps cursor to the nearest chip boundary (start or end) if inside a chip. + * Returns the original position if not inside any chip. + */ +export function snapCursorToChipBoundary( + text: string, + chips: InlineChip[], + cursorPos: number +): number { + const boundary = findChipBoundary(text, chips, cursorPos); + if (!boundary) return cursorPos; + if (cursorPos <= boundary.start || cursorPos >= boundary.end) return cursorPos; + + const distToStart = cursorPos - boundary.start; + const distToEnd = boundary.end - cursorPos; + return distToStart <= distToEnd ? boundary.start : boundary.end; +} + +// ============================================================================= +// Reconciliation +// ============================================================================= + +/** + * Returns only those chips whose tokens are still present in the text. + * Used to keep chip state in sync after paste/cut/undo operations. + */ +export function reconcileChips(oldChips: InlineChip[], newText: string): InlineChip[] { + return oldChips.filter((chip) => newText.includes(chipToken(chip))); +} + +/** + * Removes a chip token from text, including a trailing newline if present. + * This prevents orphan blank lines after chip removal. + */ +export function removeChipTokenFromText(text: string, chip: InlineChip): string { + const token = chipToken(chip); + const idx = text.indexOf(token); + if (idx === -1) return text; + + const end = idx + token.length; + // Remove trailing newline if present + const removeEnd = end < text.length && text[end] === '\n' ? end + 1 : end; + return text.slice(0, idx) + text.slice(removeEnd); +} + +// ============================================================================= +// Chip position calculation (mirror div technique) +// ============================================================================= + +export interface ChipPosition { + chip: InlineChip; + top: number; + left: number; + width: number; + height: number; +} + +/** + * Calculates screen positions of chip tokens in textarea using the mirror div technique. + * Creates a temporary mirror div that replicates textarea layout and measures chip spans. + */ +export function calculateChipPositions( + textarea: HTMLTextAreaElement, + text: string, + chips: InlineChip[] +): ChipPosition[] { + if (chips.length === 0) return []; + + const cs = window.getComputedStyle(textarea); + const mirror = document.createElement('div'); + + // Copy all relevant styles to mirror div + mirror.style.font = cs.font; + mirror.style.letterSpacing = cs.letterSpacing; + mirror.style.wordSpacing = cs.wordSpacing; + mirror.style.textIndent = cs.textIndent; + mirror.style.textTransform = cs.textTransform; + mirror.style.tabSize = cs.tabSize; + mirror.style.whiteSpace = cs.whiteSpace; + mirror.style.wordWrap = cs.wordWrap; + mirror.style.overflowWrap = cs.overflowWrap; + mirror.style.paddingTop = cs.paddingTop; + mirror.style.paddingRight = cs.paddingRight; + mirror.style.paddingBottom = cs.paddingBottom; + mirror.style.paddingLeft = cs.paddingLeft; + mirror.style.borderTopWidth = cs.borderTopWidth; + mirror.style.borderRightWidth = cs.borderRightWidth; + mirror.style.borderBottomWidth = cs.borderBottomWidth; + mirror.style.borderLeftWidth = cs.borderLeftWidth; + mirror.style.boxSizing = cs.boxSizing; + mirror.style.width = cs.width; + mirror.style.lineHeight = cs.lineHeight; + + mirror.style.position = 'absolute'; + mirror.style.top = '-9999px'; + mirror.style.left = '-9999px'; + mirror.style.visibility = 'hidden'; + mirror.style.overflow = 'hidden'; + mirror.style.height = 'auto'; + + // Build content with chip tokens wrapped in spans + const chipSpans = new Map(); + const tokenPositions: { chip: InlineChip; token: string; index: number }[] = []; + + // Find all chip token positions in text + for (const chip of chips) { + const token = chipToken(chip); + const idx = text.indexOf(token); + if (idx !== -1) { + tokenPositions.push({ chip, token, index: idx }); + } + } + + // Sort by position in text + tokenPositions.sort((a, b) => a.index - b.index); + + // Build mirror content + let lastEnd = 0; + for (const { chip, token, index } of tokenPositions) { + // Text before this chip + if (index > lastEnd) { + const textNode = document.createTextNode(text.slice(lastEnd, index)); + mirror.appendChild(textNode); + } + + // Chip span + const span = document.createElement('span'); + span.textContent = token; + mirror.appendChild(span); + chipSpans.set(chip.id, span); + + lastEnd = index + token.length; + } + + // Text after last chip + if (lastEnd < text.length) { + mirror.appendChild(document.createTextNode(text.slice(lastEnd))); + } + + document.body.appendChild(mirror); + + const positions: ChipPosition[] = []; + for (const { chip } of tokenPositions) { + const span = chipSpans.get(chip.id); + if (!span) continue; + positions.push({ + chip, + top: span.offsetTop, + left: span.offsetLeft, + width: span.offsetWidth, + height: span.offsetHeight, + }); + } + + document.body.removeChild(mirror); + return positions; +} diff --git a/src/renderer/utils/previewRegistry.ts b/src/renderer/utils/previewRegistry.ts new file mode 100644 index 00000000..c0de7391 --- /dev/null +++ b/src/renderer/utils/previewRegistry.ts @@ -0,0 +1,45 @@ +/** + * Preview type registry for binary files. + * + * Extensible: add a new PreviewType + extension set + component to support new formats. + */ + +export type PreviewType = 'image' | 'unknown'; + +const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']); + +const IMAGE_MAX_SIZE = 10 * 1024 * 1024; // 10 MB + +const MIME_MAP: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + bmp: 'image/bmp', + ico: 'image/x-icon', +}; + +function getExtension(fileName: string): string { + const dot = fileName.lastIndexOf('.'); + if (dot === -1) return ''; + return fileName.slice(dot + 1).toLowerCase(); +} + +export function getPreviewType(fileName: string): PreviewType { + const ext = getExtension(fileName); + if (IMAGE_EXTENSIONS.has(ext)) return 'image'; + return 'unknown'; +} + +export function isPreviewable(fileName: string, size: number): boolean { + const type = getPreviewType(fileName); + if (type === 'image') return size <= IMAGE_MAX_SIZE; + return false; +} + +export function getMimeType(fileName: string): string | null { + const ext = getExtension(fileName); + return MIME_MAP[ext] ?? null; +} diff --git a/src/shared/types/editor.ts b/src/shared/types/editor.ts index c76d79cf..93706c40 100644 --- a/src/shared/types/editor.ts +++ b/src/shared/types/editor.ts @@ -193,12 +193,26 @@ export interface EditorAPI { renameFile: (sourcePath: string, newName: string) => Promise; searchInFiles: (options: SearchInFilesOptions) => Promise; listFiles: () => Promise; + readBinaryPreview: (filePath: string) => Promise; gitStatus: () => Promise; watchDir: (enable: boolean) => Promise; /** Subscribe to file change events (main → renderer). Returns cleanup function. */ onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void; } +// ============================================================================= +// Binary Preview +// ============================================================================= + +export interface BinaryPreviewResult { + /** Base64-encoded file content */ + base64: string; + /** MIME type (e.g. 'image/png') */ + mimeType: string; + /** File size in bytes */ + size: number; +} + // ============================================================================= // Selection Action Menu // ============================================================================= diff --git a/test/main/services/editor/EditorFileWatcher.test.ts b/test/main/services/editor/EditorFileWatcher.test.ts index b97dd5b6..3049e485 100644 --- a/test/main/services/editor/EditorFileWatcher.test.ts +++ b/test/main/services/editor/EditorFileWatcher.test.ts @@ -41,6 +41,7 @@ import { EditorFileWatcher } from '../../../../src/main/services/editor/EditorFi describe('EditorFileWatcher', () => { let watcher: EditorFileWatcher; + const FLUSH_DEBOUNCE_MS = 350; beforeEach(() => { vi.useFakeTimers(); @@ -61,6 +62,7 @@ describe('EditorFileWatcher', () => { expect(watch).toHaveBeenCalledWith('/Users/test/project', { ignored: expect.any(RegExp), ignoreInitial: true, + ignorePermissionErrors: true, followSymlinks: false, depth: 20, }); @@ -84,7 +86,7 @@ describe('EditorFileWatcher', () => { // Simulate chokidar 'change' event const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1]; changeHandler?.('/Users/test/project/src/index.ts'); - vi.advanceTimersByTime(150); + vi.advanceTimersByTime(FLUSH_DEBOUNCE_MS); expect(onChange).toHaveBeenCalledWith({ type: 'change', @@ -98,7 +100,7 @@ describe('EditorFileWatcher', () => { const addHandler = mockOn.mock.calls.find((c) => c[0] === 'add')?.[1]; addHandler?.('/Users/test/project/new-file.ts'); - vi.advanceTimersByTime(150); + vi.advanceTimersByTime(FLUSH_DEBOUNCE_MS); expect(onChange).toHaveBeenCalledWith({ type: 'create', @@ -112,7 +114,7 @@ describe('EditorFileWatcher', () => { const unlinkHandler = mockOn.mock.calls.find((c) => c[0] === 'unlink')?.[1]; unlinkHandler?.('/Users/test/project/old-file.ts'); - vi.advanceTimersByTime(150); + vi.advanceTimersByTime(FLUSH_DEBOUNCE_MS); expect(onChange).toHaveBeenCalledWith({ type: 'delete', diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 58b36eb5..a8ebd83c 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -262,8 +262,8 @@ describe('CliInstallerService', () => { const statusPromise = service.getStatus(); - // Advance past GET_STATUS_TIMEOUT_MS (25s) - await vi.advanceTimersByTimeAsync(26_000); + // Advance past GET_STATUS_TIMEOUT_MS (30s) + await vi.advanceTimersByTimeAsync(31_000); const status = await statusPromise; @@ -295,6 +295,115 @@ describe('CliInstallerService', () => { }); }); + describe('auth parallelism', () => { + let httpsGet: ReturnType; + + beforeEach(async () => { + // Reset execCli mock queue (clearAllMocks doesn't clear mockResolvedValueOnce queue) + vi.mocked(execCli).mockReset(); + vi.mocked(execCli).mockRejectedValue(new Error('execCli not configured')); + + // Get reference to the mocked https.get for per-test control + const httpsModule = await import('https'); + httpsGet = vi.mocked(httpsModule.default.get); + }); + + afterEach(() => { + // Reset https.get so it doesn't leak into subsequent test groups + httpsGet.mockReset(); + vi.useRealTimers(); + }); + + it('auth is not blocked by slow GCS fetch', async () => { + allowConsoleLogs(); + vi.useFakeTimers(); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); + + // --version resolves immediately, auth resolves immediately + vi.mocked(execCli) + .mockResolvedValueOnce({ stdout: '2.5.0 (Claude Code)', stderr: '' }) + .mockResolvedValueOnce({ + stdout: '{"loggedIn":true,"authMethod":"api_key"}', + stderr: '', + }); + + // GCS never responds — simulates slow/hanging network. + // Returns proper req-like object so httpsGetFollowRedirects doesn't crash, + // but never fires the response callback. + httpsGet.mockImplementation(() => ({ + setTimeout: vi.fn(), + on: vi.fn(), + destroy: vi.fn(), + })); + + const statusPromise = service.getStatus(); + + // Advance past GET_STATUS_TIMEOUT_MS (30s) — GCS still hanging, + // but auth already wrote its result to `r` directly + await vi.advanceTimersByTimeAsync(31_000); + + const status = await statusPromise; + + // Auth succeeded even though GCS is hanging + expect(status.authLoggedIn).toBe(true); + expect(status.authMethod).toBe('api_key'); + expect(status.installed).toBe(true); + expect(status.installedVersion).toBe('2.5.0'); + }); + + it('auth retry works when first attempt fails', async () => { + allowConsoleLogs(); + vi.useFakeTimers(); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); + + // --version ok, auth attempt 1 fails, auth attempt 2 succeeds + vi.mocked(execCli) + .mockResolvedValueOnce({ stdout: '2.5.0', stderr: '' }) + .mockRejectedValueOnce(new Error('ENOENT stale lock')) + .mockResolvedValueOnce({ + stdout: '{"loggedIn":true,"authMethod":"oauth"}', + stderr: '', + }); + + const statusPromise = service.getStatus(); + + // Advance past retry delay (1.5s) + auth timeout + outer timeout + await vi.advanceTimersByTimeAsync(31_000); + + const status = await statusPromise; + + expect(status.authLoggedIn).toBe(true); + expect(status.authMethod).toBe('oauth'); + }); + + it('auth times out independently when both attempts hang', async () => { + allowConsoleLogs(); + vi.useFakeTimers(); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); + + // --version ok, auth hangs forever (never resolves) + vi.mocked(execCli) + .mockResolvedValueOnce({ stdout: '2.5.0', stderr: '' }) + .mockReturnValue(new Promise(() => {})); + + const statusPromise = service.getStatus(); + + // Advance past AUTH_TOTAL_TIMEOUT_MS (15s) and GET_STATUS_TIMEOUT_MS (30s) + await vi.advanceTimersByTimeAsync(31_000); + + const status = await statusPromise; + + // Auth timed out independently → stays false + expect(status.authLoggedIn).toBe(false); + expect(status.authMethod).toBeNull(); + // Version was populated before auth started + expect(status.installedVersion).toBe('2.5.0'); + }); + }); + describe('sendProgress with destroyed window', () => { it('does not throw when window is destroyed', async () => { allowConsoleLogs(); diff --git a/test/renderer/hooks/useChipDraftPersistence.test.ts b/test/renderer/hooks/useChipDraftPersistence.test.ts new file mode 100644 index 00000000..83dc778d --- /dev/null +++ b/test/renderer/hooks/useChipDraftPersistence.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock idb-keyval before importing draftStorage +const store = new Map(); + +vi.mock('idb-keyval', () => ({ + get: vi.fn((key: string) => Promise.resolve(store.get(key) ?? undefined)), + set: vi.fn((key: string, value: unknown) => { + store.set(key, value); + return Promise.resolve(); + }), + del: vi.fn((key: string) => { + store.delete(key); + return Promise.resolve(); + }), + keys: vi.fn(() => Promise.resolve([...store.keys()])), +})); + +import { draftStorage } from '@renderer/services/draftStorage'; + +import type { InlineChip } from '@renderer/types/inlineChip'; + +function makeChip(overrides: Partial = {}): InlineChip { + return { + id: 'chip-1', + filePath: '/src/auth.ts', + fileName: 'auth.ts', + fromLine: 10, + toLine: 15, + codeText: 'const x = 1;', + language: 'typescript', + ...overrides, + }; +} + +describe('chip draft persistence via draftStorage', () => { + beforeEach(() => { + store.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('saves and loads chips as JSON', async () => { + const chips = [makeChip()]; + await draftStorage.saveDraft('test:chips', JSON.stringify(chips)); + const raw = await draftStorage.loadDraft('test:chips'); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(1); + expect(parsed[0].id).toBe('chip-1'); + expect(parsed[0].filePath).toBe('/src/auth.ts'); + }); + + it('round-trips multiple chips', async () => { + const chips = [ + makeChip({ id: 'c1', fileName: 'a.ts' }), + makeChip({ id: 'c2', fileName: 'b.ts' }), + ]; + await draftStorage.saveDraft('test:chips', JSON.stringify(chips)); + const raw = await draftStorage.loadDraft('test:chips'); + const parsed = JSON.parse(raw!); + expect(parsed).toHaveLength(2); + expect(parsed[0].fileName).toBe('a.ts'); + expect(parsed[1].fileName).toBe('b.ts'); + }); + + it('deletes chip draft', async () => { + await draftStorage.saveDraft('test:chips', JSON.stringify([makeChip()])); + await draftStorage.deleteDraft('test:chips'); + const raw = await draftStorage.loadDraft('test:chips'); + expect(raw).toBeNull(); + }); + + it('returns null for non-existent key', async () => { + const raw = await draftStorage.loadDraft('nonexistent:chips'); + expect(raw).toBeNull(); + }); + + it('handles invalid JSON gracefully (at consumer level)', async () => { + await draftStorage.saveDraft('test:chips', 'not valid json{{{'); + const raw = await draftStorage.loadDraft('test:chips'); + // Raw value is returned; consumer (useChipDraftPersistence) handles parse errors + expect(raw).toBe('not valid json{{{'); + expect(() => JSON.parse(raw!)).toThrow(); + }); + + it('handles empty array', async () => { + await draftStorage.saveDraft('test:chips', JSON.stringify([])); + const raw = await draftStorage.loadDraft('test:chips'); + const parsed = JSON.parse(raw!); + expect(parsed).toEqual([]); + }); +}); diff --git a/test/renderer/types/inlineChip.test.ts b/test/renderer/types/inlineChip.test.ts new file mode 100644 index 00000000..6707a278 --- /dev/null +++ b/test/renderer/types/inlineChip.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; + +import { + CHIP_MARKER, + chipDisplayLabel, + chipToMarkdown, + chipToken, + serializeChipsWithText, +} from '@renderer/types/inlineChip'; + +import type { InlineChip } from '@renderer/types/inlineChip'; + +function makeChip(overrides: Partial = {}): InlineChip { + return { + id: 'chip-1', + filePath: '/src/auth.ts', + fileName: 'auth.ts', + fromLine: 10, + toLine: 15, + codeText: 'const x = 1;\nconst y = 2;', + language: 'typescript', + ...overrides, + }; +} + +describe('chipDisplayLabel', () => { + it('returns fileName:fromLine-toLine for multi-line', () => { + const chip = makeChip({ fromLine: 10, toLine: 15 }); + expect(chipDisplayLabel(chip)).toBe('auth.ts:10-15'); + }); + + it('returns fileName:line for single-line', () => { + const chip = makeChip({ fromLine: 42, toLine: 42 }); + expect(chipDisplayLabel(chip)).toBe('auth.ts:42'); + }); + + it('works with different file names', () => { + const chip = makeChip({ fileName: 'index.tsx', fromLine: 1, toLine: 3 }); + expect(chipDisplayLabel(chip)).toBe('index.tsx:1-3'); + }); +}); + +describe('chipToken', () => { + it('prepends CHIP_MARKER to display label', () => { + const chip = makeChip({ fromLine: 10, toLine: 15 }); + expect(chipToken(chip)).toBe(`${CHIP_MARKER}auth.ts:10-15`); + }); + + it('uses single-line format when fromLine === toLine', () => { + const chip = makeChip({ fromLine: 42, toLine: 42 }); + expect(chipToken(chip)).toBe(`${CHIP_MARKER}auth.ts:42`); + }); +}); + +describe('chipToMarkdown', () => { + it('produces markdown code fence for multi-line', () => { + const chip = makeChip({ + fromLine: 10, + toLine: 15, + codeText: 'const x = 1;', + language: 'typescript', + }); + const md = chipToMarkdown(chip); + expect(md).toContain('**auth.ts**'); + expect(md).toContain('lines 10-15'); + expect(md).toContain('```typescript'); + expect(md).toContain('const x = 1;'); + expect(md).toContain('```'); + }); + + it('produces markdown code fence for single-line', () => { + const chip = makeChip({ + fromLine: 42, + toLine: 42, + codeText: 'return true;', + }); + const md = chipToMarkdown(chip); + expect(md).toContain('line 42'); + expect(md).not.toContain('lines'); + }); + + it('uses language from chip', () => { + const chip = makeChip({ language: 'python', fileName: 'script.py' }); + expect(chipToMarkdown(chip)).toContain('```python'); + }); +}); + +describe('serializeChipsWithText', () => { + it('returns text unchanged when no chips', () => { + expect(serializeChipsWithText('hello world', [])).toBe('hello world'); + }); + + it('replaces chip token with markdown', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `Hello\n${token}\nGoodbye`; + const result = serializeChipsWithText(text, [chip]); + expect(result).toContain('**auth.ts**'); + expect(result).toContain('```typescript'); + expect(result).not.toContain(CHIP_MARKER); + expect(result).toContain('Hello'); + expect(result).toContain('Goodbye'); + }); + + it('handles multiple chips', () => { + const chip1 = makeChip({ id: 'c1', fileName: 'a.ts', fromLine: 1, toLine: 3 }); + const chip2 = makeChip({ id: 'c2', fileName: 'b.ts', fromLine: 10, toLine: 20 }); + const text = `${chipToken(chip1)}\n${chipToken(chip2)}`; + const result = serializeChipsWithText(text, [chip1, chip2]); + expect(result).toContain('**a.ts**'); + expect(result).toContain('**b.ts**'); + expect(result).not.toContain(CHIP_MARKER); + }); + + it('preserves text around chips', () => { + const chip = makeChip(); + const text = `Before ${chipToken(chip)} after`; + const result = serializeChipsWithText(text, [chip]); + expect(result).toContain('Before '); + expect(result).toContain(' after'); + }); +}); diff --git a/test/renderer/utils/chipUtils.test.ts b/test/renderer/utils/chipUtils.test.ts new file mode 100644 index 00000000..a2b9a4ad --- /dev/null +++ b/test/renderer/utils/chipUtils.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; + +import { chipToken } from '@renderer/types/inlineChip'; +import { + createChipFromSelection, + findChipBoundary, + isInsideChip, + reconcileChips, + removeChipTokenFromText, + snapCursorToChipBoundary, +} from '@renderer/utils/chipUtils'; + +import type { InlineChip } from '@renderer/types/inlineChip'; +import type { EditorSelectionAction } from '@shared/types/editor'; + +function makeChip(overrides: Partial = {}): InlineChip { + return { + id: 'chip-test-1', + filePath: '/src/auth.ts', + fileName: 'auth.ts', + fromLine: 10, + toLine: 15, + codeText: 'const x = 1;', + language: 'typescript', + ...overrides, + }; +} + +function makeAction(overrides: Partial = {}): EditorSelectionAction { + return { + type: 'sendMessage', + filePath: '/src/auth.ts', + fromLine: 10, + toLine: 15, + selectedText: 'const x = 1;', + formattedContext: '**auth.ts** (lines 10-15):\n```typescript\nconst x = 1;\n```', + ...overrides, + }; +} + +describe('createChipFromSelection', () => { + it('creates a chip from an EditorSelectionAction', () => { + const action = makeAction(); + const chip = createChipFromSelection(action, []); + expect(chip).not.toBeNull(); + expect(chip!.filePath).toBe('/src/auth.ts'); + expect(chip!.fileName).toBe('auth.ts'); + expect(chip!.fromLine).toBe(10); + expect(chip!.toLine).toBe(15); + expect(chip!.codeText).toBe('const x = 1;'); + expect(chip!.language).toBe('typescript'); + expect(chip!.id).toMatch(/^chip-/); + }); + + it('returns null for duplicate (same filePath + lines)', () => { + const existing = makeChip(); + const action = makeAction(); + expect(createChipFromSelection(action, [existing])).toBeNull(); + }); + + it('allows different line ranges for the same file', () => { + const existing = makeChip({ fromLine: 1, toLine: 5 }); + const action = makeAction({ fromLine: 10, toLine: 15 }); + expect(createChipFromSelection(action, [existing])).not.toBeNull(); + }); +}); + +describe('findChipBoundary', () => { + it('returns boundary when cursor is at chip end', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `Hello ${token} world`; + const cursorPos = 6 + token.length; // at end of token + const boundary = findChipBoundary(text, [chip], cursorPos); + expect(boundary).not.toBeNull(); + expect(boundary!.start).toBe(6); + expect(boundary!.end).toBe(6 + token.length); + expect(boundary!.chip).toBe(chip); + }); + + it('returns boundary when cursor is at chip start', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `Hello ${token} world`; + const boundary = findChipBoundary(text, [chip], 6); + expect(boundary).not.toBeNull(); + expect(boundary!.start).toBe(6); + }); + + it('returns boundary when cursor is inside chip', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `Hello ${token} world`; + const boundary = findChipBoundary(text, [chip], 8); + expect(boundary).not.toBeNull(); + }); + + it('returns null when cursor is not near any chip', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `Hello ${token} world`; + // cursor at beginning + expect(findChipBoundary(text, [chip], 0)).toBeNull(); + }); + + it('handles multiple chips', () => { + const chip1 = makeChip({ id: 'c1', fileName: 'a.ts', fromLine: 1, toLine: 1 }); + const chip2 = makeChip({ id: 'c2', fileName: 'b.ts', fromLine: 2, toLine: 2 }); + const t1 = chipToken(chip1); + const t2 = chipToken(chip2); + const text = `${t1} ${t2}`; + const boundary = findChipBoundary(text, [chip1, chip2], t1.length + 1); + expect(boundary).not.toBeNull(); + expect(boundary!.chip.id).toBe('c2'); + }); +}); + +describe('isInsideChip', () => { + it('returns true when cursor is strictly inside', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `X${token}Y`; + expect(isInsideChip(text, [chip], 3)).toBe(true); + }); + + it('returns false at chip start boundary', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `X${token}Y`; + expect(isInsideChip(text, [chip], 1)).toBe(false); + }); + + it('returns false at chip end boundary', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `X${token}Y`; + expect(isInsideChip(text, [chip], 1 + token.length)).toBe(false); + }); + + it('returns false outside chip', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `X${token}Y`; + expect(isInsideChip(text, [chip], 0)).toBe(false); + }); +}); + +describe('snapCursorToChipBoundary', () => { + it('snaps to nearest start when cursor is closer to start', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `X${token}Y`; + // position 2 is inside, closer to start (1) + const snapped = snapCursorToChipBoundary(text, [chip], 2); + expect(snapped).toBe(1); + }); + + it('snaps to nearest end when cursor is closer to end', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `X${token}Y`; + // position close to end + const nearEnd = 1 + token.length - 1; + const snapped = snapCursorToChipBoundary(text, [chip], nearEnd); + expect(snapped).toBe(1 + token.length); + }); + + it('returns original position if not inside chip', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `X${token}Y`; + expect(snapCursorToChipBoundary(text, [chip], 0)).toBe(0); + }); +}); + +describe('reconcileChips', () => { + it('keeps chips whose tokens are present', () => { + const chip = makeChip(); + const text = `Hello ${chipToken(chip)} world`; + expect(reconcileChips([chip], text)).toEqual([chip]); + }); + + it('removes chips whose tokens are missing', () => { + const chip = makeChip(); + expect(reconcileChips([chip], 'Hello world')).toEqual([]); + }); + + it('handles partial removal (only some chips gone)', () => { + const chip1 = makeChip({ id: 'c1', fileName: 'a.ts', fromLine: 1, toLine: 1 }); + const chip2 = makeChip({ id: 'c2', fileName: 'b.ts', fromLine: 2, toLine: 2 }); + const text = chipToken(chip1); // only chip1 token present + const result = reconcileChips([chip1, chip2], text); + expect(result).toEqual([chip1]); + }); +}); + +describe('removeChipTokenFromText', () => { + it('removes the token from text', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `Hello ${token} world`; + expect(removeChipTokenFromText(text, chip)).toBe('Hello world'); + }); + + it('removes trailing newline', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `${token}\nHello`; + expect(removeChipTokenFromText(text, chip)).toBe('Hello'); + }); + + it('does not alter text when token not found', () => { + const chip = makeChip(); + const text = 'Hello world'; + expect(removeChipTokenFromText(text, chip)).toBe('Hello world'); + }); + + it('removes token from middle of text', () => { + const chip = makeChip(); + const token = chipToken(chip); + const text = `A\n${token}\nB`; + expect(removeChipTokenFromText(text, chip)).toBe('A\nB'); + }); +});