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.
This commit is contained in:
iliya 2026-03-01 23:51:51 +02:00
parent 91412dccc1
commit e1585b4f71
38 changed files with 2372 additions and 173 deletions

View file

@ -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<IpcResult<GitStatusResult>> {
});
}
/**
* Read binary file as base64 for inline preview.
*/
async function handleEditorReadBinaryPreview(
_event: IpcMainInvokeEvent,
filePath: string
): Promise<IpcResult<BinaryPreviewResult>> {
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);
}

View file

@ -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<string, EditorFileChangeEvent['type']>();
private flushTimer: ReturnType<typeof setTimeout> | 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,
});

View file

@ -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<SearchMatch[]> {
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;

View file

@ -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);

View file

@ -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<string, string> = {
'.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<ReadDirResult> {
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<BinaryPreviewResult> {
// 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
// ---------------------------------------------------------------------------

View file

@ -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<void> {
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<void> {
const doCheck = async (): Promise<void> => {
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<void>((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<void> {
try {
const latestRaw = await fetchText(`${GCS_BASE}/latest`);
r.latestVersion = normalizeVersion(latestRaw);

View file

@ -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';

View file

@ -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<SearchInFilesResult>(EDITOR_SEARCH_IN_FILES, options),
listFiles: () => invokeIpcWithResult<QuickOpenFile[]>(EDITOR_LIST_FILES),
readBinaryPreview: (filePath: string) =>
invokeIpcWithResult<BinaryPreviewResult>(EDITOR_READ_BINARY_PREVIEW, filePath),
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {

View file

@ -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');
},

View file

@ -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<string | undefined>(undefined);
const [sendDialogDefaultText, setSendDialogDefaultText] = useState<string | undefined>(undefined);
const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState<InlineChip | undefined>(
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);
}}
/>

View file

@ -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<string>(defaultOwner);
const [blockedBy, setBlockedBy] = useState<string[]>([]);
const [related, setRelated] = useState<string[]>([]);
@ -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={

View file

@ -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<QuotedMessage | undefined>(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<SendMessageResult | null>(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={

View file

@ -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 (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<FileQuestion className="size-12 opacity-30" />
<p className="text-sm font-medium text-text-secondary">{fileName}</p>
<p className="text-xs">Binary file ({sizeFormatted})</p>
<Button variant="outline" size="sm" className="mt-2" onClick={handleOpenExternal}>
Open in System Viewer
</Button>
</div>
);
};

View file

@ -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 <EditorImagePreview filePath={filePath} fileName={fileName} size={size} />;
}
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<FileQuestion className="size-12 opacity-30" />
<p className="text-sm font-medium text-text-secondary">{fileName}</p>
<p className="text-xs">Binary file ({sizeFormatted})</p>
<Button variant="outline" size="sm" className="mt-2" onClick={handleOpenExternal}>
Open in System Viewer
</Button>
</div>
);
return <EditorBinaryPlaceholder filePath={filePath} fileName={fileName} size={size} />;
};

View file

@ -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 (
<div className="flex items-center gap-0.5 overflow-x-auto px-3 py-1 text-xs text-text-muted">

View file

@ -75,7 +75,7 @@ export const EditorContextMenu = ({
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<div ref={triggerRef} onContextMenu={handleContextMenu}>
<div ref={triggerRef} onContextMenu={handleContextMenu} className="h-full">
{children}
</div>
</ContextMenu.Trigger>

View file

@ -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<string, GitFileStatusType>();
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 (
<div
ref={combinedRef}
className={`h-full overflow-y-auto transition-colors ${
className={`scrollbar-thin h-full overflow-y-auto transition-colors ${
isDropTarget ? 'bg-blue-400/5 ring-1 ring-inset ring-blue-400/30' : ''
}`}
role="tree"

View file

@ -0,0 +1,131 @@
/**
* Inline image preview for the project editor.
*
* Loads binary file as base64 data URL via IPC, displays centered image
* with checkerboard background for transparency, metadata, and lightbox on click.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { Loader2 } from 'lucide-react';
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
interface EditorImagePreviewProps {
filePath: string;
fileName: string;
size: number;
}
export const EditorImagePreview = ({
filePath,
fileName,
size,
}: EditorImagePreviewProps): React.ReactElement => {
const projectPath = useStore((s) => s.editorProjectPath);
const [dataUrl, setDataUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
const imgRef = useRef<HTMLImageElement>(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 (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Loader2 className="size-8 animate-spin opacity-40" />
<p className="text-xs">Loading preview</p>
</div>
);
}
if (error || !dataUrl) {
return <EditorBinaryPlaceholder filePath={filePath} fileName={fileName} size={size} />;
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
<button
type="button"
className="checkerboard-bg flex max-h-[60vh] max-w-[80%] cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border border-border-subtle p-1"
onClick={() => setLightboxOpen(true)}
aria-label="Open full-size preview"
>
<img
ref={imgRef}
src={dataUrl}
alt={fileName}
className="max-h-[60vh] object-contain"
onLoad={handleImageLoad}
draggable={false}
/>
</button>
<p className="text-xs text-text-muted">
{fileName}
{dimensions ? `${dimensions.w}×${dimensions.h}` : ''}
{`${sizeFormatted}`}
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleOpenExternal}>
Open in System Viewer
</Button>
</div>
<ImageLightbox
src={dataUrl}
alt={fileName}
open={lightboxOpen}
onClose={() => setLightboxOpen(false)}
/>
</div>
);
};

View file

@ -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>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex size-[22px] items-center justify-center rounded transition-colors',
active
? 'bg-blue-500/20 text-blue-400'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]'
)}
onClick={onClick}
tabIndex={-1}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{tooltip}</span>
{shortcut && <span className="ml-1.5 text-[var(--color-text-muted)]">{shortcut}</span>}
</TooltipContent>
</Tooltip>
);
}: SearchToggleButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex size-[22px] items-center justify-center rounded transition-colors',
active
? 'bg-blue-500/20 text-blue-400'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]'
)}
onClick={onClick}
tabIndex={-1}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{tooltip}</span>
{shortcut && <span className="ml-1.5 text-[var(--color-text-muted)]">{shortcut}</span>}
</TooltipContent>
</Tooltip>
);
});
// =============================================================================
// Match counter

View file

@ -27,6 +27,7 @@ export const EditorStatusBar = React.memo(function EditorStatusBar({
watcherEnabled: s.editorWatcherEnabled,
}))
);
const toggleWatcher = useStore((s) => s.toggleWatcher);
return (
<div className="flex h-6 shrink-0 items-center justify-between border-t border-border bg-surface-sidebar px-3 text-[11px] text-text-muted">
@ -42,14 +43,26 @@ export const EditorStatusBar = React.memo(function EditorStatusBar({
)}
</div>
<div className="flex items-center gap-4">
{watcherEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-green-400">watching</span>
</TooltipTrigger>
<TooltipContent side="top">File watcher active</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void toggleWatcher(!watcherEnabled)}
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
watcherEnabled
? 'bg-green-500/15 text-green-400 hover:bg-green-500/20'
: 'text-text-muted hover:bg-surface-raised hover:text-text-secondary'
}`}
aria-label={watcherEnabled ? 'Disable file watcher' : 'Enable file watcher'}
aria-pressed={watcherEnabled}
>
{watcherEnabled ? 'watching' : 'watch'}
</button>
</TooltipTrigger>
<TooltipContent side="top">
{watcherEnabled ? 'Disable external change watcher' : 'Watch for external changes'}
</TooltipContent>
</Tooltip>
<span>{language}</span>
<span>UTF-8</span>
<span>Spaces: 2</span>

View file

@ -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 => (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={onClick}
disabled={disabled}
className={`h-auto gap-1 px-1.5 py-0.5 text-xs ${
active ? 'bg-surface-raised text-text' : 'text-text-muted'
}`}
aria-label={`${label} (${shortcut})`}
aria-pressed={active}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{label} ({shortcut})
</TooltipContent>
</Tooltip>
);
}: ToolbarButtonProps): React.ReactElement {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={onClick}
disabled={disabled}
className={`h-auto gap-1 px-1.5 py-0.5 text-xs ${
active ? 'bg-surface-raised text-text' : 'text-text-muted'
}`}
aria-label={`${label} (${shortcut})`}
aria-pressed={active}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{label} ({shortcut})
</TooltipContent>
</Tooltip>
);
});

View file

@ -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

View file

@ -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<HTMLInputElement>(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}

View file

@ -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<HTMLDivElement>(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 (
<div className="max-w-md overflow-hidden rounded-md">
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--code-bg,#1e1e2e)] px-2.5 py-1.5">
<span className="text-[11px] font-medium text-[var(--color-text)]">{label}</span>
<span className="text-[10px] text-[var(--color-text-muted)]">{lineRef}</span>
</div>
<div ref={containerRef} />
{truncated ? (
<div className="border-t border-[var(--color-border)] bg-[var(--code-bg,#1e1e2e)] px-2.5 py-1">
<span className="text-[10px] text-[var(--color-text-muted)]">
({allLines.length - MAX_PREVIEW_LINES} more lines...)
</span>
</div>
) : null}
</div>
);
};
// =============================================================================
// Interaction layer
// =============================================================================
interface ChipInteractionLayerProps {
chips: InlineChip[];
value: string;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
scrollTop: number;
onRemove: (chipId: string) => void;
}
export const ChipInteractionLayer = ({
chips,
value,
textareaRef,
scrollTop,
onRemove,
}: ChipInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<ChipPosition[]>([]);
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 (
<div className="pointer-events-none absolute inset-0 z-20 overflow-hidden">
<div style={{ transform: `translateY(-${scrollTop}px)` }}>
{positions.map((pos) => (
<Tooltip key={pos.chip.id}>
<TooltipTrigger asChild>
<div
className="group pointer-events-auto absolute cursor-default"
style={{
top: pos.top,
left: pos.left,
width: pos.width,
height: pos.height,
}}
>
<button
type="button"
className="absolute -right-1 -top-1.5 z-30 flex size-3.5 items-center justify-center rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove(pos.chip.id);
}}
>
<X size={8} className="text-[var(--color-text-muted)]" />
</button>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md p-0">
<ChipCodePreview chip={pos.chip} />
</TooltipContent>
</Tooltip>
))}
</div>
</div>
);
};

View file

@ -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 (
<span
style={{
backgroundColor: CHIP_BG,
color: CHIP_TEXT,
borderRadius: '4px',
boxShadow: `0 0 0 1.5px ${CHIP_BG}`,
}}
>
{tokenText}
</span>
);
};

View file

@ -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<string, InlineChip>();
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<HTMLTextAreaElement, MentionableTextareaProps>(
@ -129,6 +210,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
showHint = true,
footerRight,
cornerAction,
chips = [],
onChipRemove,
style,
className,
...textareaProps
@ -137,6 +220,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
) => {
const internalRef = React.useRef<HTMLTextAreaElement | null>(null);
const backdropRef = React.useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = React.useState(0);
const setRefs = React.useCallback(
(node: HTMLTextAreaElement | null) => {
@ -158,9 +242,9 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
selectedIndex,
dropdownPosition,
selectSuggestion,
handleKeyDown,
handleChange,
handleSelect,
handleKeyDown: mentionHandleKeyDown,
handleChange: mentionHandleChange,
handleSelect: mentionHandleSelect,
} = useMentionDetection({
suggestions,
value,
@ -169,8 +253,6 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
});
// Sync backdrop font with textarea computed font to prevent caret drift.
// Chromium UA stylesheet may apply different font-family / letter-spacing
// to <textarea> vs <div>, 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<HTMLTextAreaElement, Mention
backdrop.style.textIndent = cs.textIndent;
backdrop.style.textTransform = cs.textTransform;
backdrop.style.tabSize = cs.tabSize;
}, [value]); // re-sync when value changes (textarea may reflow)
}, [value]);
// --- Mention overlay ---
const hasMentionOverlay = suggestions.length > 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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
handleChipKeyDown(e);
if (!e.defaultPrevented) {
mentionHandleKeyDown(e);
}
},
[handleChipKeyDown, mentionHandleKeyDown]
);
// --- Chip reconciliation on text change ---
const composedHandleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement, Mention
<div className="relative">
{/* Inner wrapper for textarea + backdrop overlay */}
<div className="relative">
{hasMentionOverlay ? (
{hasOverlay ? (
<div
ref={backdropRef}
className={cn(
@ -237,6 +466,10 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
if (seg.type === 'text') {
return <React.Fragment key={idx}>{seg.value}</React.Fragment>;
}
if (seg.type === 'chip') {
return <CodeChipBadge key={idx} chip={seg.chip} tokenText={seg.value} />;
}
// mention
const colorSet = seg.suggestion.color
? getTeamColorSet(seg.suggestion.color)
: null;
@ -262,14 +495,25 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
<AutoResizeTextarea
ref={setRefs}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onSelect={handleSelect}
onChange={composedHandleChange}
onKeyDown={composedHandleKeyDown}
onSelect={composedHandleSelect}
{...textareaProps}
className={cn(className, cornerAction && 'pb-12 pr-[4.25rem]')}
onScroll={handleScroll}
style={textareaStyle}
/>
{chips.length > 0 && onChipRemove ? (
<ChipInteractionLayer
chips={chips}
value={value}
textareaRef={internalRef}
scrollTop={scrollTop}
onRemove={handleChipRemove}
/>
) : null}
{cornerAction ? (
<div className="pointer-events-none absolute bottom-2 right-2 z-20 flex items-end justify-end">
<div className="pointer-events-auto">{cornerAction}</div>

View file

@ -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<InlineChip[]>([]);
const [isSaved, setIsSaved] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<InlineChip[] | null>(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 };
}

View file

@ -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;
}

View file

@ -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<V>(record: Record<string, V>, key: string): Record<string, V> {
if (!(key in record)) return record;
const result = { ...record };
delete result[key];
return result;
@ -44,6 +45,32 @@ function omitKey<V>(record: Record<string, V>, key: string): Record<string, V> {
const recentSaveTimestamps = new Map<string, number>();
const SAVE_COOLDOWN_MS = 2000;
/**
* Throttle timers for watcher-driven updates.
* Keeping these module-level avoids store re-renders during bursts.
*/
let gitStatusThrottleTimer: ReturnType<typeof setTimeout> | null = null;
const GIT_STATUS_THROTTLE_MS = 1500;
const gitStatusChangeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const GIT_STATUS_CHANGE_DEBOUNCE_MS = 6000;
const dirRefreshDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
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<typeof setTimeout> | null = null;
let watcherEventCounts: Record<EditorFileChangeEvent['type'], number> = {
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<string, number>();
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<AppState, [], [], EditorSlice> = (s
// ═══════════════════════════════════════════════════════
openEditor: async (projectPath: string) => {
const openSeq = ++editorOpenSeq;
set({
editorProjectPath: projectPath,
editorFileTree: null,
@ -222,20 +266,60 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<void> {
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<V>(
/**
* 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;
}

View file

@ -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;
}

View file

@ -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<string, HTMLSpanElement>();
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;
}

View file

@ -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<string, string> = {
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;
}

View file

@ -193,12 +193,26 @@ export interface EditorAPI {
renameFile: (sourcePath: string, newName: string) => Promise<MoveFileResponse>;
searchInFiles: (options: SearchInFilesOptions) => Promise<SearchInFilesResult>;
listFiles: () => Promise<QuickOpenFile[]>;
readBinaryPreview: (filePath: string) => Promise<BinaryPreviewResult>;
gitStatus: () => Promise<GitStatusResult>;
watchDir: (enable: boolean) => Promise<void>;
/** 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
// =============================================================================

View file

@ -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',

View file

@ -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<typeof vi.fn>;
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();

View file

@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock idb-keyval before importing draftStorage
const store = new Map<string, unknown>();
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> = {}): 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([]);
});
});

View file

@ -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> = {}): 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');
});
});

View file

@ -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> = {}): 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> = {}): 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');
});
});