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:
parent
91412dccc1
commit
e1585b4f71
38 changed files with 2372 additions and 173 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
131
src/renderer/components/team/editor/EditorImagePreview.tsx
Normal file
131
src/renderer/components/team/editor/EditorImagePreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
178
src/renderer/components/ui/ChipInteractionLayer.tsx
Normal file
178
src/renderer/components/ui/ChipInteractionLayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/renderer/components/ui/CodeChipBadge.tsx
Normal file
32
src/renderer/components/ui/CodeChipBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
129
src/renderer/hooks/useChipDraftPersistence.ts
Normal file
129
src/renderer/hooks/useChipDraftPersistence.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
81
src/renderer/types/inlineChip.ts
Normal file
81
src/renderer/types/inlineChip.ts
Normal 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;
|
||||
}
|
||||
245
src/renderer/utils/chipUtils.ts
Normal file
245
src/renderer/utils/chipUtils.ts
Normal 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;
|
||||
}
|
||||
45
src/renderer/utils/previewRegistry.ts
Normal file
45
src/renderer/utils/previewRegistry.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
96
test/renderer/hooks/useChipDraftPersistence.test.ts
Normal file
96
test/renderer/hooks/useChipDraftPersistence.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
122
test/renderer/types/inlineChip.test.ts
Normal file
122
test/renderer/types/inlineChip.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
224
test/renderer/utils/chipUtils.test.ts
Normal file
224
test/renderer/utils/chipUtils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue