feat: enhance task management with new file renaming feature and notification settings
- Added a new file renaming functionality in the editor, allowing users to rename files and directories in place. - Introduced notification settings for team inbox messages and task clarifications, enabling users to receive native OS notifications for important updates. - Updated the README to reflect the new features and provide a clearer overview of the task management capabilities. - Improved the application icon handling for notifications across different platforms.
This commit is contained in:
parent
cb8017b0db
commit
f4f02d5536
55 changed files with 2793 additions and 535 deletions
13
README.md
13
README.md
|
|
@ -19,6 +19,19 @@
|
|||
|
||||
<br />
|
||||
|
||||
## What is this
|
||||
|
||||
A new approach to task management with AI agents.
|
||||
|
||||
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
|
||||
- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments
|
||||
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
|
||||
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
|
||||
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
|
||||
- **Stay in control** — send a direct message to any agent or drop a comment on a task whenever you want to clarify something or add new work
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/2b420b2c-c4af-4d10-a679-c83269f8ee99">
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
|||
import { showTeamNativeNotification } from './ipc/teams';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||
import { getAppIconPath } from './utils/appIcon';
|
||||
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||
import {
|
||||
CliInstallerService,
|
||||
|
|
@ -126,10 +127,20 @@ async function resolveTeamDisplayName(teamName: string): Promise<string> {
|
|||
}
|
||||
|
||||
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
|
||||
// Check config toggle
|
||||
const config = configManager.getConfig();
|
||||
if (!config.notifications.enabled || !config.notifications.notifyOnInboxMessages) return;
|
||||
|
||||
// detail is like "inboxes/carol.json" — extract member name
|
||||
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
||||
if (!match) return;
|
||||
const memberName = match[1];
|
||||
|
||||
// Only notify for the lead's inbox (messages addressed to the human user).
|
||||
// CLI doesn't set msg.to, so we filter by inbox file name instead.
|
||||
const leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null;
|
||||
if (leadName !== null && memberName !== leadName && memberName !== 'user') return;
|
||||
|
||||
const key = `${teamName}:${memberName}`;
|
||||
|
||||
try {
|
||||
|
|
@ -154,8 +165,6 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
const teamDisplayName = await resolveTeamDisplayName(teamName);
|
||||
|
||||
for (const msg of newMessages) {
|
||||
// Only notify for messages addressed to the human user
|
||||
if (msg.to !== 'user') continue;
|
||||
// Skip messages sent from our own UI
|
||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||
|
||||
|
|
@ -173,24 +182,6 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
}
|
||||
}
|
||||
|
||||
// Window icon path for non-mac platforms.
|
||||
const getWindowIconPath = (): string | undefined => {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const candidates = isDev
|
||||
? [join(process.cwd(), 'resources/icon.png')]
|
||||
: [
|
||||
join(process.resourcesPath, 'resources/icon.png'),
|
||||
join(__dirname, '../../resources/icon.png'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('Unhandled promise rejection in main process:', reason);
|
||||
});
|
||||
|
|
@ -349,21 +340,24 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
// Show native OS notification for live lead process replies.
|
||||
// These don't go through inbox files — they're held in-memory by TeamProvisioningService.
|
||||
if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') {
|
||||
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
|
||||
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
// Only notify for messages addressed to the human user
|
||||
if (latest?.to === 'user') {
|
||||
const fromLabel = latest.from || 'team-lead';
|
||||
const summary = latest.summary || latest.text.slice(0, 60);
|
||||
void resolveTeamDisplayName(teamName)
|
||||
.then((displayName) => {
|
||||
showTeamNativeNotification({
|
||||
title: displayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: latest.text,
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
const cfg = configManager.getConfig();
|
||||
if (cfg.notifications.enabled && cfg.notifications.notifyOnInboxMessages) {
|
||||
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
|
||||
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
// Only notify for messages addressed to the human user
|
||||
if (latest?.to === 'user') {
|
||||
const fromLabel = latest.from || 'team-lead';
|
||||
const summary = latest.summary || latest.text.slice(0, 60);
|
||||
void resolveTeamDisplayName(teamName)
|
||||
.then((displayName) => {
|
||||
showTeamNativeNotification({
|
||||
title: displayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: latest.text,
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -690,7 +684,7 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
|
|||
*/
|
||||
function createWindow(): void {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const iconPath = isMac ? undefined : getWindowIconPath();
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar;
|
||||
mainWindow = new BrowserWindow({
|
||||
width: DEFAULT_WINDOW_WIDTH,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
EDITOR_OPEN,
|
||||
EDITOR_READ_DIR,
|
||||
EDITOR_READ_FILE,
|
||||
EDITOR_RENAME_FILE,
|
||||
EDITOR_SEARCH_IN_FILES,
|
||||
EDITOR_WATCH_DIR,
|
||||
EDITOR_WRITE_FILE,
|
||||
|
|
@ -244,6 +245,20 @@ async function handleEditorMoveFile(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a file or directory in place.
|
||||
*/
|
||||
async function handleEditorRenameFile(
|
||||
_event: IpcMainInvokeEvent,
|
||||
sourcePath: string,
|
||||
newName: string
|
||||
): Promise<IpcResult<MoveFileResponse>> {
|
||||
return wrapHandler('renameFile', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
return projectFileService.renameFile(activeProjectRoot, sourcePath, newName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search in files (literal string search, SEC-8 timeout).
|
||||
*/
|
||||
|
|
@ -344,6 +359,7 @@ export function registerEditorHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(EDITOR_CREATE_DIR, handleEditorCreateDir);
|
||||
ipcMain.handle(EDITOR_DELETE_FILE, handleEditorDeleteFile);
|
||||
ipcMain.handle(EDITOR_MOVE_FILE, handleEditorMoveFile);
|
||||
ipcMain.handle(EDITOR_RENAME_FILE, handleEditorRenameFile);
|
||||
ipcMain.handle(EDITOR_SEARCH_IN_FILES, handleEditorSearchInFiles);
|
||||
ipcMain.handle(EDITOR_LIST_FILES, handleEditorListFiles);
|
||||
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
|
||||
|
|
@ -360,6 +376,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(EDITOR_CREATE_DIR);
|
||||
ipcMain.removeHandler(EDITOR_DELETE_FILE);
|
||||
ipcMain.removeHandler(EDITOR_MOVE_FILE);
|
||||
ipcMain.removeHandler(EDITOR_RENAME_FILE);
|
||||
ipcMain.removeHandler(EDITOR_SEARCH_IN_FILES);
|
||||
ipcMain.removeHandler(EDITOR_LIST_FILES);
|
||||
ipcMain.removeHandler(EDITOR_GIT_STATUS);
|
||||
|
|
|
|||
|
|
@ -221,7 +221,44 @@ async function handleApplyDecisions(
|
|||
if (!request || !Array.isArray(request.decisions)) {
|
||||
return { success: false, error: 'Invalid request: decisions array required' };
|
||||
}
|
||||
return wrapReviewHandler('applyDecisions', () => getApplier().applyReviewDecisions(request));
|
||||
return wrapReviewHandler('applyDecisions', async () => {
|
||||
// Build file contents map for the applier. Prefer renderer-provided context
|
||||
// (snippets + full contents), falling back to resolver when missing.
|
||||
const fileContents = new Map<string, FileChangeWithContent>();
|
||||
const memberName = request.memberName ?? '';
|
||||
|
||||
for (const d of request.decisions) {
|
||||
const snippets = d.snippets ?? [];
|
||||
|
||||
// If renderer provided full contents, use them directly.
|
||||
if (d.originalFullContent !== undefined || d.modifiedFullContent !== undefined) {
|
||||
fileContents.set(d.filePath, {
|
||||
filePath: d.filePath,
|
||||
relativePath: d.filePath.split('/').slice(-3).join('/'),
|
||||
snippets,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
isNewFile: d.isNewFile ?? snippets.some((s) => s.type === 'write-new'),
|
||||
originalFullContent: d.originalFullContent ?? null,
|
||||
modifiedFullContent: d.modifiedFullContent ?? null,
|
||||
// Source is informational only; "unavailable" avoids lying.
|
||||
contentSource: 'unavailable',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: resolve in main process (best-effort; task mode may not have memberName).
|
||||
const resolved = await getContentResolver().getFileContent(
|
||||
request.teamName,
|
||||
memberName,
|
||||
d.filePath,
|
||||
snippets
|
||||
);
|
||||
fileContents.set(d.filePath, resolved);
|
||||
}
|
||||
|
||||
return getApplier().applyReviewDecisions(request, fileContents);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetFileContent(
|
||||
|
|
@ -241,12 +278,14 @@ async function handleGetFileContent(
|
|||
async function handleSaveEditedFile(
|
||||
_event: IpcMainInvokeEvent,
|
||||
filePath: string,
|
||||
content: string
|
||||
content: string,
|
||||
projectPath?: string
|
||||
): Promise<IpcResult<{ success: boolean }>> {
|
||||
if (!filePath || typeof content !== 'string') {
|
||||
return { success: false, error: 'Invalid parameters' };
|
||||
}
|
||||
const pathCheck = validateFilePath(filePath, null);
|
||||
const resolvedProjectPath = projectPath && typeof projectPath === 'string' ? projectPath : null;
|
||||
const pathCheck = validateFilePath(filePath, resolvedProjectPath);
|
||||
if (!pathCheck.valid) {
|
||||
logger.error(`saveEditedFile blocked: ${String(pathCheck.error)} (path: ${String(filePath)})`);
|
||||
return { success: false, error: `Path validation failed: ${String(pathCheck.error)}` };
|
||||
|
|
@ -284,6 +323,7 @@ async function handleLoadDecisions(
|
|||
IpcResult<{
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
} | null>
|
||||
> {
|
||||
return wrapReviewHandler('loadDecisions', () => reviewDecisionStore.load(teamName, scopeKey));
|
||||
|
|
@ -294,10 +334,15 @@ async function handleSaveDecisions(
|
|||
teamName: string,
|
||||
scopeKey: string,
|
||||
hunkDecisions: Record<string, HunkDecision>,
|
||||
fileDecisions: Record<string, HunkDecision>
|
||||
fileDecisions: Record<string, HunkDecision>,
|
||||
hunkContextHashesByFile: Record<string, Record<number, string>> | null = null
|
||||
): Promise<IpcResult<void>> {
|
||||
return wrapReviewHandler('saveDecisions', () =>
|
||||
reviewDecisionStore.save(teamName, scopeKey, { hunkDecisions, fileDecisions })
|
||||
reviewDecisionStore.save(teamName, scopeKey, {
|
||||
hunkDecisions,
|
||||
fileDecisions,
|
||||
hunkContextHashesByFile: hunkContextHashesByFile ?? undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import {
|
||||
TEAM_ADD_MEMBER,
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
|
|
@ -1725,11 +1726,13 @@ export function showTeamNativeNotification(opts: {
|
|||
return;
|
||||
}
|
||||
|
||||
const iconPath = getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: opts.title,
|
||||
subtitle: opts.subtitle,
|
||||
body: opts.body.slice(0, 300),
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
|
|
|
|||
|
|
@ -545,6 +545,67 @@ export class ProjectFileService {
|
|||
return { newPath, isDirectory };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a file or directory in place (same parent directory).
|
||||
*/
|
||||
async renameFile(
|
||||
projectRoot: string,
|
||||
sourcePath: string,
|
||||
newName: string
|
||||
): Promise<MoveFileResponse> {
|
||||
// 1. Validate new name
|
||||
const nameValidation = validateFileName(newName);
|
||||
if (!nameValidation.valid) {
|
||||
throw new Error(nameValidation.error);
|
||||
}
|
||||
|
||||
// 2. Validate source path
|
||||
const srcValidation = validateFilePath(sourcePath, projectRoot);
|
||||
if (!srcValidation.valid) {
|
||||
throw new Error(srcValidation.error);
|
||||
}
|
||||
const normalizedSrc = srcValidation.normalizedPath!;
|
||||
|
||||
// 3. Project containment
|
||||
if (!isPathWithinRoot(normalizedSrc, projectRoot)) {
|
||||
throw new Error('Source path is outside project root');
|
||||
}
|
||||
|
||||
// 4. Block .git/ paths
|
||||
if (isGitInternalPath(normalizedSrc)) {
|
||||
throw new Error('Cannot rename files in .git/ directory');
|
||||
}
|
||||
|
||||
// 5. Verify source exists
|
||||
const srcStat = await fs.lstat(normalizedSrc);
|
||||
const isDirectory = srcStat.isDirectory();
|
||||
|
||||
// 6. Build new path (same parent, new name)
|
||||
const parentDir = path.dirname(normalizedSrc);
|
||||
const newPath = path.join(parentDir, newName);
|
||||
|
||||
// 7. Check new path doesn't already exist
|
||||
try {
|
||||
await fs.access(newPath);
|
||||
throw new Error('A file or folder with that name already exists');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Block sensitive destination
|
||||
if (matchesSensitivePattern(newPath)) {
|
||||
throw new Error('Cannot rename to a sensitive file name');
|
||||
}
|
||||
|
||||
// 9. Perform rename
|
||||
await fs.rename(normalizedSrc, newPath);
|
||||
|
||||
log.info('File renamed:', normalizedSrc, '→', newPath);
|
||||
return { newPath, isDirectory };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ export interface NotificationConfig {
|
|||
snoozeMinutes: number; // Default snooze duration
|
||||
/** Whether to include errors from subagent sessions */
|
||||
includeSubagentErrors: boolean;
|
||||
/** Whether to show native OS notifications for team inbox messages */
|
||||
notifyOnInboxMessages: boolean;
|
||||
/** Whether to show native OS notifications when a task needs user clarification */
|
||||
notifyOnClarifications: boolean;
|
||||
/** Notification triggers - define when to generate notifications */
|
||||
triggers: NotificationTrigger[];
|
||||
}
|
||||
|
|
@ -243,6 +247,8 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
snoozedUntil: null,
|
||||
snoozeMinutes: 30,
|
||||
includeSubagentErrors: true,
|
||||
notifyOnInboxMessages: true,
|
||||
notifyOnClarifications: true,
|
||||
triggers: DEFAULT_TRIGGERS,
|
||||
},
|
||||
general: {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
* - Emit IPC events to renderer: notification:new, notification:updated
|
||||
*/
|
||||
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { type BrowserWindow, Notification } from 'electron';
|
||||
|
|
@ -394,11 +395,13 @@ export class NotificationManager extends EventEmitter {
|
|||
|
||||
const config = this.configManager.getConfig();
|
||||
|
||||
const iconPath = getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: 'Claude Code Error',
|
||||
subtitle: error.context.projectName,
|
||||
body: error.message.slice(0, 200),
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||
import { structuredPatch } from 'diff';
|
||||
|
||||
import type { SnippetDiff } from '@shared/types';
|
||||
|
|
@ -36,6 +37,7 @@ export class HunkSnippetMatcher {
|
|||
if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue;
|
||||
const hunk = patch.hunks[hunkIdx];
|
||||
const snippetSet = new Set<number>();
|
||||
const strongMatches = new Set<number>();
|
||||
|
||||
// Reconstruct old/new side of hunk INCLUDING context lines.
|
||||
// Context lines (` ` prefix) are critical — without them, snippets whose
|
||||
|
|
@ -55,9 +57,18 @@ export class HunkSnippetMatcher {
|
|||
if (this.hasContentOverlap(snippet, oldSideContent, newSideContent)) {
|
||||
snippetSet.add(sIdx);
|
||||
}
|
||||
|
||||
// Strong match: contextHash matches the hunk's contextual fingerprint.
|
||||
// This reduces false positives when repeated patterns exist in a file.
|
||||
if (snippet.contextHash) {
|
||||
const h = computeDiffContextHash(oldSideContent, newSideContent);
|
||||
if (h === snippet.contextHash) {
|
||||
strongMatches.add(sIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mapping.set(hunkIdx, snippetSet);
|
||||
mapping.set(hunkIdx, strongMatches.size > 0 ? strongMatches : snippetSet);
|
||||
}
|
||||
|
||||
return mapping;
|
||||
|
|
@ -134,17 +145,20 @@ export class HunkSnippetMatcher {
|
|||
if (!snippet.newString && !snippet.oldString) return false;
|
||||
|
||||
if (snippet.type === 'write-new' || snippet.type === 'write-update') {
|
||||
// For Write: snippet.newString is the full file content — check if hunk's new side is within it
|
||||
if (snippet.newString && hunkNewSide) {
|
||||
return snippet.newString.includes(hunkNewSide);
|
||||
}
|
||||
// Full-file writes are intentionally excluded from localized hunk↔snippet matching.
|
||||
// They are handled by whole-file reject logic or hunk-level inverse patch.
|
||||
return false;
|
||||
}
|
||||
|
||||
// For Edit/MultiEdit: check if snippet falls within hunk's file range
|
||||
const matchesOld = snippet.oldString ? hunkOldSide.includes(snippet.oldString) : false;
|
||||
const matchesNew = snippet.newString ? hunkNewSide.includes(snippet.newString) : false;
|
||||
const hasOld = snippet.oldString.length > 0;
|
||||
const hasNew = snippet.newString.length > 0;
|
||||
const matchesOld = hasOld ? hunkOldSide.includes(snippet.oldString) : false;
|
||||
const matchesNew = hasNew ? hunkNewSide.includes(snippet.newString) : false;
|
||||
|
||||
return matchesOld || matchesNew;
|
||||
// Prefer stricter matching when both sides exist to avoid over-matching.
|
||||
if (hasOld && hasNew) return matchesOld && matchesNew;
|
||||
if (hasOld) return matchesOld;
|
||||
return matchesNew;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { applyPatch, structuredPatch } from 'diff';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { diff3Merge } from 'node-diff3';
|
||||
|
||||
import { HunkSnippetMatcher } from './HunkSnippetMatcher';
|
||||
|
|
@ -247,6 +248,68 @@ export class ReviewApplierService {
|
|||
const original = fileContent.originalFullContent;
|
||||
const modified = fileContent.modifiedFullContent;
|
||||
|
||||
const rejectedHunkIndices = Object.entries(decision.hunkDecisions)
|
||||
.filter(([, d]) => d === 'rejected')
|
||||
.map(([idx]) => parseInt(idx, 10));
|
||||
|
||||
const allHunksRejected =
|
||||
Object.keys(decision.hunkDecisions).length > 0 &&
|
||||
Object.values(decision.hunkDecisions).every((d) => d === 'rejected');
|
||||
const hasWriteNewSnippet = fileContent.snippets.some((s) => s.type === 'write-new');
|
||||
|
||||
// Special case: rejecting an entirely new file should remove it from disk.
|
||||
// IMPORTANT: Do NOT delete on partial reject — users may want to keep parts of the new file.
|
||||
const shouldDeleteNewFile =
|
||||
fileContent.isNewFile &&
|
||||
hasWriteNewSnippet &&
|
||||
original === '' &&
|
||||
(decision.fileDecision === 'rejected' || allHunksRejected);
|
||||
|
||||
if (shouldDeleteNewFile) {
|
||||
// If we have an expected modified baseline, guard against deleting a user-modified file.
|
||||
if (modified !== null) {
|
||||
const conflict = await this.checkConflict(decision.filePath, modified);
|
||||
if (conflict.hasConflict) {
|
||||
conflicts++;
|
||||
errors.push({
|
||||
filePath: decision.filePath,
|
||||
error:
|
||||
'File was modified since review was computed; refusing to delete new file automatically.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// No baseline — safest behavior is to only treat "already missing" as success.
|
||||
try {
|
||||
await readFile(decision.filePath, 'utf8');
|
||||
} catch {
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
errors.push({
|
||||
filePath: decision.filePath,
|
||||
error: 'Cannot delete new file: expected modified content is unavailable.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(decision.filePath);
|
||||
applied++;
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes('ENOENT')) {
|
||||
applied++;
|
||||
} else {
|
||||
errors.push({
|
||||
filePath: decision.filePath,
|
||||
error: `Failed to delete new file: ${msg}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (original === null || modified === null) {
|
||||
errors.push({
|
||||
filePath: decision.filePath,
|
||||
|
|
@ -275,21 +338,27 @@ export class ReviewApplierService {
|
|||
}
|
||||
} else {
|
||||
// Partial reject — only specific hunks
|
||||
const rejectedHunkIndices = Object.entries(decision.hunkDecisions)
|
||||
.filter(([, d]) => d === 'rejected')
|
||||
.map(([idx]) => parseInt(idx, 10));
|
||||
|
||||
if (rejectedHunkIndices.length === 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const mappedRejected =
|
||||
decision.hunkContextHashes && Object.keys(decision.hunkContextHashes).length > 0
|
||||
? mapRejectedHunkIndicesByHash(
|
||||
original,
|
||||
modified,
|
||||
rejectedHunkIndices,
|
||||
decision.hunkContextHashes
|
||||
)
|
||||
: rejectedHunkIndices;
|
||||
|
||||
const result = await this.rejectHunks(
|
||||
request.teamName,
|
||||
decision.filePath,
|
||||
original,
|
||||
modified,
|
||||
rejectedHunkIndices,
|
||||
mappedRejected,
|
||||
fileContent.snippets
|
||||
);
|
||||
|
||||
|
|
@ -336,7 +405,12 @@ export class ReviewApplierService {
|
|||
hunkIndices: number[],
|
||||
snippets: SnippetDiff[]
|
||||
): RejectResult | null {
|
||||
const validSnippets = snippets.filter((s) => !s.isError);
|
||||
// Safety: never use full-file Write snippets for snippet-level rejection.
|
||||
// They are not localized, and matching a single hunk to a full-file write
|
||||
// can incorrectly delete/overwrite large parts of the file.
|
||||
const validSnippets = snippets.filter(
|
||||
(s) => !s.isError && s.type !== 'write-new' && s.type !== 'write-update'
|
||||
);
|
||||
if (validSnippets.length === 0) return null;
|
||||
|
||||
// Pass pre-filtered snippets — matcher returns indices relative to this array
|
||||
|
|
@ -347,6 +421,15 @@ export class ReviewApplierService {
|
|||
validSnippets
|
||||
);
|
||||
|
||||
// Safety: if any requested hunk maps ambiguously, do NOT attempt snippet-level replacement.
|
||||
// Fall back to hunk-level inverse patch which is positional and safer.
|
||||
for (const hunkIdx of hunkIndices) {
|
||||
const set = hunkToSnippets.get(hunkIdx);
|
||||
if (set?.size !== 1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all unique snippet indices to reject
|
||||
const snippetIndices = new Set<number>();
|
||||
for (const indices of hunkToSnippets.values()) {
|
||||
|
|
@ -450,6 +533,55 @@ export class ReviewApplierService {
|
|||
}
|
||||
}
|
||||
|
||||
function buildHunkHashIndexMap(original: string, modified: string): Map<string, number[]> {
|
||||
const patch = structuredPatch('file', 'file', original, modified);
|
||||
const hunks = patch.hunks ?? [];
|
||||
const map = new Map<string, number[]>();
|
||||
for (let i = 0; i < hunks.length; i++) {
|
||||
const hunk = hunks[i];
|
||||
const oldSideContent = hunk.lines
|
||||
.filter((l) => !l.startsWith('+'))
|
||||
.map((l) => l.slice(1))
|
||||
.join('\n');
|
||||
const newSideContent = hunk.lines
|
||||
.filter((l) => !l.startsWith('-'))
|
||||
.map((l) => l.slice(1))
|
||||
.join('\n');
|
||||
const hash = computeDiffContextHash(oldSideContent, newSideContent);
|
||||
const arr = map.get(hash);
|
||||
if (arr) arr.push(i);
|
||||
else map.set(hash, [i]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function mapRejectedHunkIndicesByHash(
|
||||
original: string,
|
||||
modified: string,
|
||||
rejectedIndices: number[],
|
||||
hunkContextHashes: Record<number, string>
|
||||
): number[] {
|
||||
const hashMap = buildHunkHashIndexMap(original, modified);
|
||||
const out = new Set<number>();
|
||||
|
||||
for (const idx of rejectedIndices) {
|
||||
const hash = hunkContextHashes[idx];
|
||||
if (!hash) {
|
||||
out.add(idx);
|
||||
continue;
|
||||
}
|
||||
const candidates = hashMap.get(hash);
|
||||
if (candidates?.length === 1) {
|
||||
out.add(candidates[0]);
|
||||
} else {
|
||||
// Ambiguous or missing — fall back to index to preserve prior behavior.
|
||||
out.add(idx);
|
||||
}
|
||||
}
|
||||
|
||||
return [...out].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// ── Module-level helpers ──
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ const logger = createLogger('ReviewDecisionStore');
|
|||
export interface ReviewDecisionsData {
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
/** filePath -> (hunkIndex -> contextHash) */
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +32,7 @@ export class ReviewDecisionStore {
|
|||
): Promise<{
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
} | null> {
|
||||
const filePath = this.getFilePath(teamName, scopeKey);
|
||||
|
||||
|
|
@ -62,8 +65,12 @@ export class ReviewDecisionStore {
|
|||
data.hunkDecisions && typeof data.hunkDecisions === 'object' ? data.hunkDecisions : {};
|
||||
const fileDecisions: Record<string, HunkDecision> =
|
||||
data.fileDecisions && typeof data.fileDecisions === 'object' ? data.fileDecisions : {};
|
||||
const hunkContextHashesByFile: Record<string, Record<number, string>> | undefined =
|
||||
data.hunkContextHashesByFile && typeof data.hunkContextHashesByFile === 'object'
|
||||
? data.hunkContextHashesByFile
|
||||
: undefined;
|
||||
|
||||
return { hunkDecisions, fileDecisions };
|
||||
return { hunkDecisions, fileDecisions, hunkContextHashesByFile };
|
||||
}
|
||||
|
||||
async save(
|
||||
|
|
@ -72,12 +79,14 @@ export class ReviewDecisionStore {
|
|||
data: {
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const payload: ReviewDecisionsData = {
|
||||
hunkDecisions: data.hunkDecisions,
|
||||
fileDecisions: data.fileDecisions,
|
||||
hunkContextHashesByFile: data.hunkContextHashesByFile,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await atomicWriteAsync(
|
||||
|
|
|
|||
38
src/main/utils/appIcon.ts
Normal file
38
src/main/utils/appIcon.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Resolves the application icon path for native notifications and windows.
|
||||
*
|
||||
* On macOS the signed bundle provides the icon automatically,
|
||||
* so this is primarily needed for Windows and Linux.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
let cachedPath: string | undefined;
|
||||
let resolved = false;
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the app icon (PNG), or undefined if not found.
|
||||
* Result is cached after the first call.
|
||||
*/
|
||||
export function getAppIconPath(): string | undefined {
|
||||
if (resolved) return cachedPath;
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const candidates = isDev
|
||||
? [join(process.cwd(), 'resources/icon.png')]
|
||||
: [
|
||||
join(process.resourcesPath, 'resources/icon.png'),
|
||||
join(__dirname, '../../resources/icon.png'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
cachedPath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
resolved = true;
|
||||
return cachedPath;
|
||||
}
|
||||
|
|
@ -431,6 +431,9 @@ export const EDITOR_DELETE_FILE = 'editor:deleteFile';
|
|||
/** Move file or directory to a new location */
|
||||
export const EDITOR_MOVE_FILE = 'editor:moveFile';
|
||||
|
||||
/** Rename file or directory in place */
|
||||
export const EDITOR_RENAME_FILE = 'editor:renameFile';
|
||||
|
||||
/** Search in files (literal string search) */
|
||||
export const EDITOR_SEARCH_IN_FILES = 'editor:searchInFiles';
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
EDITOR_OPEN,
|
||||
EDITOR_READ_DIR,
|
||||
EDITOR_READ_FILE,
|
||||
EDITOR_RENAME_FILE,
|
||||
EDITOR_SEARCH_IN_FILES,
|
||||
EDITOR_WATCH_DIR,
|
||||
EDITOR_WRITE_FILE,
|
||||
|
|
@ -858,28 +859,36 @@ const electronAPI: ElectronAPI = {
|
|||
);
|
||||
},
|
||||
// Editable diff
|
||||
saveEditedFile: async (filePath: string, content: string) => {
|
||||
return invokeIpcWithResult<{ success: boolean }>(REVIEW_SAVE_EDITED_FILE, filePath, content);
|
||||
saveEditedFile: async (filePath: string, content: string, projectPath?: string) => {
|
||||
return invokeIpcWithResult<{ success: boolean }>(
|
||||
REVIEW_SAVE_EDITED_FILE,
|
||||
filePath,
|
||||
content,
|
||||
projectPath
|
||||
);
|
||||
},
|
||||
// Decision persistence
|
||||
loadDecisions: async (teamName: string, scopeKey: string) => {
|
||||
return invokeIpcWithResult<{
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
} | null>(REVIEW_LOAD_DECISIONS, teamName, scopeKey);
|
||||
},
|
||||
saveDecisions: async (
|
||||
teamName: string,
|
||||
scopeKey: string,
|
||||
hunkDecisions: Record<string, HunkDecision>,
|
||||
fileDecisions: Record<string, HunkDecision>
|
||||
fileDecisions: Record<string, HunkDecision>,
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>
|
||||
) => {
|
||||
return invokeIpcWithResult<void>(
|
||||
REVIEW_SAVE_DECISIONS,
|
||||
teamName,
|
||||
scopeKey,
|
||||
hunkDecisions,
|
||||
fileDecisions
|
||||
fileDecisions,
|
||||
hunkContextHashesByFile ?? null
|
||||
);
|
||||
},
|
||||
clearDecisions: async (teamName: string, scopeKey: string) => {
|
||||
|
|
@ -974,6 +983,8 @@ const electronAPI: ElectronAPI = {
|
|||
invokeIpcWithResult<DeleteFileResponse>(EDITOR_DELETE_FILE, filePath),
|
||||
moveFile: (sourcePath: string, destDir: string) =>
|
||||
invokeIpcWithResult<MoveFileResponse>(EDITOR_MOVE_FILE, sourcePath, destDir),
|
||||
renameFile: (sourcePath: string, newName: string) =>
|
||||
invokeIpcWithResult<MoveFileResponse>(EDITOR_RENAME_FILE, sourcePath, newName),
|
||||
searchInFiles: (options: SearchInFilesOptions) =>
|
||||
invokeIpcWithResult<SearchInFilesResult>(EDITOR_SEARCH_IN_FILES, options),
|
||||
listFiles: () => invokeIpcWithResult<QuickOpenFile[]>(EDITOR_LIST_FILES),
|
||||
|
|
|
|||
|
|
@ -863,7 +863,13 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
loadDecisions: async (): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
saveDecisions: async (): Promise<never> => {
|
||||
saveDecisions: async (
|
||||
_teamName: string,
|
||||
_scopeKey: string,
|
||||
_hunkDecisions: Record<string, unknown>,
|
||||
_fileDecisions: Record<string, unknown>,
|
||||
_hunkContextHashesByFile?: Record<string, Record<number, string>>
|
||||
): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
clearDecisions: async (): Promise<never> => {
|
||||
|
|
@ -944,6 +950,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
moveFile: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
renameFile: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
searchInFiles: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ export interface SafeConfig {
|
|||
snoozedUntil: number | null;
|
||||
snoozeMinutes: number;
|
||||
includeSubagentErrors: boolean;
|
||||
notifyOnInboxMessages: boolean;
|
||||
notifyOnClarifications: boolean;
|
||||
triggers: AppConfig['notifications']['triggers'];
|
||||
};
|
||||
display: {
|
||||
|
|
@ -169,6 +171,8 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
|
||||
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
|
||||
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true,
|
||||
notifyOnInboxMessages: displayConfig?.notifications?.notifyOnInboxMessages ?? true,
|
||||
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
||||
triggers: displayConfig?.notifications?.triggers ?? [],
|
||||
},
|
||||
display: {
|
||||
|
|
|
|||
|
|
@ -287,6 +287,8 @@ export function useSettingsHandlers({
|
|||
snoozedUntil: null,
|
||||
snoozeMinutes: 30,
|
||||
includeSubagentErrors: true,
|
||||
notifyOnInboxMessages: true,
|
||||
notifyOnClarifications: true,
|
||||
triggers: defaultTriggers,
|
||||
},
|
||||
general: {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,12 @@ interface NotificationsSectionProps {
|
|||
readonly ignoredRepositoryItems: RepositoryDropdownItem[];
|
||||
readonly excludedRepositoryIds: string[];
|
||||
readonly onNotificationToggle: (
|
||||
key: 'enabled' | 'soundEnabled' | 'includeSubagentErrors',
|
||||
key:
|
||||
| 'enabled'
|
||||
| 'soundEnabled'
|
||||
| 'includeSubagentErrors'
|
||||
| 'notifyOnInboxMessages'
|
||||
| 'notifyOnClarifications',
|
||||
value: boolean
|
||||
) => void;
|
||||
readonly onSnooze: (minutes: number) => Promise<void>;
|
||||
|
|
@ -130,6 +135,26 @@ export const NotificationsSection = ({
|
|||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Team inbox notifications"
|
||||
description="Show native OS notifications when teammates send messages to the lead"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnInboxMessages}
|
||||
onChange={(v) => onNotificationToggle('notifyOnInboxMessages', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Task clarification notifications"
|
||||
description="Show native OS notifications when a task needs your input"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnClarifications}
|
||||
onChange={(v) => onNotificationToggle('notifyOnClarifications', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Snooze notifications"
|
||||
description={
|
||||
|
|
|
|||
|
|
@ -1529,6 +1529,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
memberName={reviewDialogState.memberName}
|
||||
taskId={reviewDialogState.taskId}
|
||||
initialFilePath={reviewDialogState.initialFilePath}
|
||||
projectPath={data.config.projectPath}
|
||||
onEditorAction={handleEditorAction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { useCallback, useEffect, useRef } from 'react';
|
|||
|
||||
import { defaultKeymap, history, historyKeymap, redo, undo } from '@codemirror/commands';
|
||||
import { bracketMatching, indentOnInput, syntaxHighlighting } from '@codemirror/language';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { gotoLine, search, searchKeymap } from '@codemirror/search';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import {
|
||||
|
|
@ -20,11 +20,16 @@ import {
|
|||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import {
|
||||
createSearchPanel,
|
||||
editorSearchPanelTheme,
|
||||
} from '@renderer/components/team/editor/EditorSearchPanel';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getAsyncLanguageDesc,
|
||||
getSyncLanguageExtension,
|
||||
} from '@renderer/utils/codemirrorLanguages';
|
||||
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
|
||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
|
||||
|
|
@ -40,9 +45,6 @@ const DIRTY_DEBOUNCE_MS = 300;
|
|||
const AUTOSAVE_DELAY_MS = 30_000;
|
||||
const MAX_DRAFT_SIZE = 500 * 1024; // 500KB
|
||||
const MAX_DRAFTS = 10;
|
||||
const SELECTION_DEBOUNCE_MS = 150;
|
||||
const MAX_SELECTION_TEXT = 5000;
|
||||
|
||||
/** Compartment for dynamic line wrap toggling */
|
||||
const lineWrapCompartment = new Compartment();
|
||||
|
||||
|
|
@ -67,35 +69,6 @@ interface CodeMirrorEditorProps {
|
|||
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Selection info helper
|
||||
// =============================================================================
|
||||
|
||||
function buildSelectionInfo(
|
||||
view: EditorView,
|
||||
sel: { from: number; to: number }
|
||||
): EditorSelectionInfo | null {
|
||||
const coords = view.coordsAtPos(sel.to);
|
||||
if (!coords) return null; // selection end is off-screen
|
||||
|
||||
let text = view.state.sliceDoc(sel.from, sel.to);
|
||||
if (text.length > MAX_SELECTION_TEXT) {
|
||||
text = text.slice(0, MAX_SELECTION_TEXT) + '…';
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
filePath: '', // filled by parent (CodeMirrorEditor has no file context in buildEditableExtensions)
|
||||
fromLine: view.state.doc.lineAt(sel.from).number,
|
||||
toLine: view.state.doc.lineAt(sel.to).number,
|
||||
screenRect: {
|
||||
top: coords.top,
|
||||
right: coords.right ?? coords.left,
|
||||
bottom: coords.bottom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Extensions builder
|
||||
// =============================================================================
|
||||
|
|
@ -126,8 +99,9 @@ function buildEditableExtensions(
|
|||
// History
|
||||
history(),
|
||||
|
||||
// Search (Cmd+F)
|
||||
search(),
|
||||
// Search (Cmd+F) — custom panel with UI Kit
|
||||
search({ createPanel: createSearchPanel }),
|
||||
editorSearchPanelTheme,
|
||||
|
||||
// Save keymap (Cmd+S / Ctrl+S)
|
||||
keymap.of([
|
||||
|
|
@ -150,7 +124,12 @@ function buildEditableExtensions(
|
|||
]),
|
||||
|
||||
// Keymaps
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||
// Filter out built-in gotoLine (Alt-g) — replaced by custom GoToLineDialog
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap.filter((k) => k.run !== gotoLine),
|
||||
]),
|
||||
|
||||
// Update listener for dirty flag + cursor position + selection
|
||||
EditorView.updateListener.of((update) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Placeholder for binary files — shows file info and "Open in System Viewer" button.
|
||||
*/
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { FileQuestion } from 'lucide-react';
|
||||
|
||||
interface EditorBinaryStateProps {
|
||||
|
|
@ -30,12 +31,9 @@ export const EditorBinaryState = ({
|
|||
<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
|
||||
onClick={handleOpenExternal}
|
||||
className="mt-2 rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={handleOpenExternal}>
|
||||
Open in System Viewer
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
import { FilePlus, FolderOpen, FolderPlus, Trash2 } from 'lucide-react';
|
||||
import { ClipboardCopy, FilePlus, FolderOpen, FolderPlus, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
|
|
@ -23,9 +23,11 @@ interface TargetEntry {
|
|||
|
||||
interface EditorContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
projectPath: string | null;
|
||||
onNewFile: (parentDir: string) => void;
|
||||
onNewFolder: (parentDir: string) => void;
|
||||
onDelete: (path: string) => void;
|
||||
onRename: (path: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -34,9 +36,11 @@ interface EditorContextMenuProps {
|
|||
|
||||
export const EditorContextMenu = ({
|
||||
children,
|
||||
projectPath,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: EditorContextMenuProps): React.ReactElement => {
|
||||
const [target, setTarget] = useState<TargetEntry | null>(null);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -102,6 +106,15 @@ export const EditorContextMenu = ({
|
|||
|
||||
{target && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={target.isSensitive}
|
||||
onSelect={() => onRename(target.path)}
|
||||
>
|
||||
<Pencil className="size-3.5 text-text-muted" />
|
||||
Rename
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-red-400 outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={target.isSensitive}
|
||||
|
|
@ -116,15 +129,40 @@ export const EditorContextMenu = ({
|
|||
)}
|
||||
|
||||
{target && (
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => {
|
||||
void window.electronAPI.showInFolder(target.path);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="size-3.5 text-text-muted" />
|
||||
Reveal in Finder
|
||||
</ContextMenu.Item>
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => void navigator.clipboard.writeText(target.path)}
|
||||
>
|
||||
<ClipboardCopy className="size-3.5 text-text-muted" />
|
||||
Copy Path
|
||||
</ContextMenu.Item>
|
||||
|
||||
{projectPath && target.path.startsWith(projectPath) && (
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => {
|
||||
const relative = target.path.slice(projectPath.length + 1);
|
||||
void navigator.clipboard.writeText(relative);
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy className="size-3.5 text-text-muted" />
|
||||
Copy Relative Path
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
|
||||
<ContextMenu.Separator className="my-1 h-px bg-border" />
|
||||
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => {
|
||||
void window.electronAPI.showInFolder(target.path);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="size-3.5 text-text-muted" />
|
||||
Reveal in Finder
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Error state for file read failures (EACCES, ENOENT, etc.).
|
||||
*/
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface EditorErrorStateProps {
|
||||
|
|
@ -25,22 +26,14 @@ export const EditorErrorState = ({
|
|||
<p className="max-w-md text-center text-sm text-text-secondary">{error}</p>
|
||||
<div className="flex gap-2">
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close Tab
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ import {
|
|||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
|
@ -78,11 +87,14 @@ export const EditorFileTree = ({
|
|||
const createDirInTree = useStore((s) => s.createDirInTree);
|
||||
const deleteFileFromTree = useStore((s) => s.deleteFileFromTree);
|
||||
const moveFileInTree = useStore((s) => s.moveFileInTree);
|
||||
const renameFileInTree = useStore((s) => s.renameFileInTree);
|
||||
const openFile = useStore((s) => s.openFile);
|
||||
const gitFiles = useStore((s) => s.editorGitFiles);
|
||||
const projectPath = useStore((s) => s.editorProjectPath);
|
||||
|
||||
const [newItemState, setNewItemState] = useState<NewItemState | null>(null);
|
||||
const [renamingPath, setRenamingPath] = useState<string | null>(null);
|
||||
const [deleteConfirmPath, setDeleteConfirmPath] = useState<string | null>(null);
|
||||
const [draggedItem, setDraggedItem] = useState<FlatTreeItem | null>(null);
|
||||
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null);
|
||||
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
|
@ -200,16 +212,37 @@ export const EditorFileTree = ({
|
|||
[projectPath, expandedDirs, expandDirectory]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (path: string) => {
|
||||
const fileName = path.split('/').pop() ?? path;
|
||||
const confirmed = window.confirm(`Move "${fileName}" to Trash?`);
|
||||
if (!confirmed) return;
|
||||
await deleteFileFromTree(path);
|
||||
const handleDelete = useCallback((path: string) => {
|
||||
setDeleteConfirmPath(path);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteConfirmPath) return;
|
||||
await deleteFileFromTree(deleteConfirmPath);
|
||||
setDeleteConfirmPath(null);
|
||||
}, [deleteConfirmPath, deleteFileFromTree]);
|
||||
|
||||
const handleCancelDelete = useCallback(() => {
|
||||
setDeleteConfirmPath(null);
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback((path: string) => {
|
||||
setRenamingPath(path);
|
||||
}, []);
|
||||
|
||||
const handleRenameSubmit = useCallback(
|
||||
async (newName: string) => {
|
||||
if (!renamingPath) return;
|
||||
await renameFileInTree(renamingPath, newName);
|
||||
setRenamingPath(null);
|
||||
},
|
||||
[deleteFileFromTree]
|
||||
[renamingPath, renameFileInTree]
|
||||
);
|
||||
|
||||
const handleRenameCancel = useCallback(() => {
|
||||
setRenamingPath(null);
|
||||
}, []);
|
||||
|
||||
const handleNewItemSubmit = useCallback(
|
||||
async (name: string) => {
|
||||
if (!newItemState) return;
|
||||
|
|
@ -360,9 +393,11 @@ export const EditorFileTree = ({
|
|||
|
||||
return (
|
||||
<EditorContextMenu
|
||||
projectPath={projectPath}
|
||||
onNewFile={handleNewFile}
|
||||
onNewFolder={handleNewFolder}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
|
|
@ -424,6 +459,9 @@ export const EditorFileTree = ({
|
|||
dropTargetPath={dropTargetPath}
|
||||
isDragActive={!!draggedItem}
|
||||
onClick={handleNodeClick}
|
||||
isRenaming={renamingPath === item.node.fullPath}
|
||||
onRenameSubmit={handleRenameSubmit}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${virtualItem.start}px`,
|
||||
|
|
@ -444,6 +482,26 @@ export const EditorFileTree = ({
|
|||
{draggedItem && <DragOverlayFileItem item={draggedItem} />}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={!!deleteConfirmPath} onOpenChange={(open) => !open && handleCancelDelete()}>
|
||||
<DialogContent className="w-96 max-w-96">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Move to Trash</DialogTitle>
|
||||
<DialogDescription>
|
||||
Move “{deleteConfirmPath?.split('/').pop() ?? ''}” to Trash?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancelDelete}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => void handleConfirmDelete()}>
|
||||
Move to Trash
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</EditorContextMenu>
|
||||
);
|
||||
};
|
||||
|
|
@ -499,6 +557,9 @@ interface DraggableTreeItemProps {
|
|||
isDragActive: boolean;
|
||||
onClick: (node: TreeNode<FileTreeEntry>) => void;
|
||||
style: React.CSSProperties;
|
||||
isRenaming?: boolean;
|
||||
onRenameSubmit?: (newName: string) => void;
|
||||
onRenameCancel?: () => void;
|
||||
}
|
||||
|
||||
/* eslint-disable react/jsx-props-no-spreading -- dnd-kit requires prop spreading for drag attributes, listeners, and data attributes */
|
||||
|
|
@ -511,6 +572,9 @@ const DraggableTreeItem = React.memo(
|
|||
isDragActive,
|
||||
onClick,
|
||||
style,
|
||||
isRenaming,
|
||||
onRenameSubmit,
|
||||
onRenameCancel,
|
||||
}: DraggableTreeItemProps): React.ReactElement => {
|
||||
const { node, depth, isExpanded } = item;
|
||||
const isSelected = activeNodePath === node.fullPath;
|
||||
|
|
@ -545,8 +609,10 @@ const DraggableTreeItem = React.memo(
|
|||
[setDragRef, setDropRef, node.isFile]
|
||||
);
|
||||
|
||||
// Visual: highlight drop target directory
|
||||
// Visual: highlight drop target directory and its visible children
|
||||
const isDropTarget = !node.isFile && dropTargetPath === node.fullPath;
|
||||
const isInsideDropTarget =
|
||||
dropTargetPath != null && node.fullPath.startsWith(dropTargetPath + '/');
|
||||
|
||||
const dataAttrs: Record<string, string> = {};
|
||||
if (node.data) {
|
||||
|
|
@ -589,7 +655,7 @@ const DraggableTreeItem = React.memo(
|
|||
isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary'
|
||||
} ${isDragging ? 'opacity-30' : ''} ${
|
||||
isDropTarget ? 'rounded bg-blue-400/10 ring-2 ring-blue-400/50' : ''
|
||||
}`}
|
||||
} ${isInsideDropTarget && !isDropTarget ? 'border-l-2 border-l-blue-400/40 bg-blue-400/5' : ''}`}
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft: `${visualDepth * INDENT_PX + 8}px`,
|
||||
|
|
@ -609,8 +675,16 @@ const DraggableTreeItem = React.memo(
|
|||
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
||||
))}
|
||||
{icon}
|
||||
<span className="truncate">{node.name}</span>
|
||||
{node.data && gitStatusMap.has(node.data.path) && (
|
||||
{isRenaming ? (
|
||||
<InlineRenameInput
|
||||
initialName={node.name}
|
||||
onSubmit={onRenameSubmit!}
|
||||
onCancel={onRenameCancel!}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate">{node.name}</span>
|
||||
)}
|
||||
{!isRenaming && node.data && gitStatusMap.has(node.data.path) && (
|
||||
<GitStatusBadge status={gitStatusMap.get(node.data.path)!} />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -643,6 +717,89 @@ const DragOverlayFileItem = ({ item }: { item: FlatTreeItem }): React.ReactEleme
|
|||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Inline rename input
|
||||
// =============================================================================
|
||||
|
||||
const InlineRenameInput = ({
|
||||
initialName,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initialName: string;
|
||||
onSubmit: (newName: string) => void;
|
||||
onCancel: () => void;
|
||||
}): React.ReactElement => {
|
||||
const [value, setValue] = useState(initialName);
|
||||
const submitted = useRef(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus + select on mount (delayed to survive Radix/DnD focus interference)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
const dotIdx = initialName.lastIndexOf('.');
|
||||
if (dotIdx > 0) {
|
||||
input.setSelectionRange(0, dotIdx);
|
||||
} else {
|
||||
input.select();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [initialName]);
|
||||
|
||||
// Click-outside → submit (replaces unreliable onBlur)
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (e: PointerEvent): void => {
|
||||
if (inputRef.current && !inputRef.current.contains(e.target as Node)) {
|
||||
doSubmit();
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
}, 150);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- doSubmit reads value via ref pattern
|
||||
}, []);
|
||||
|
||||
const doSubmit = (): void => {
|
||||
if (submitted.current) return;
|
||||
submitted.current = true;
|
||||
const trimmed = inputRef.current?.value.trim() ?? '';
|
||||
if (trimmed && trimmed !== initialName) {
|
||||
onSubmit(trimmed);
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
doSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onBlur={() => requestAnimationFrame(() => inputRef.current?.focus())}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="min-w-0 flex-1 rounded border border-blue-400/50 bg-surface px-1 py-0 text-xs text-text outline-none focus:ring-1 focus:ring-blue-400/50"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
|
|
|||
506
src/renderer/components/team/editor/EditorSearchPanel.tsx
Normal file
506
src/renderer/components/team/editor/EditorSearchPanel.tsx
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
/**
|
||||
* Custom CodeMirror search/replace panel using the project UI Kit.
|
||||
*
|
||||
* Replaces the default CodeMirror search panel with a styled version
|
||||
* that uses our Input, Button, and Tooltip components for consistent
|
||||
* design language across the app.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import {
|
||||
closeSearchPanel,
|
||||
findNext,
|
||||
findPrevious,
|
||||
getSearchQuery,
|
||||
replaceAll,
|
||||
replaceNext,
|
||||
SearchQuery,
|
||||
setSearchQuery,
|
||||
} from '@codemirror/search';
|
||||
import { EditorView, type Panel } from '@codemirror/view';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CaseSensitive,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Regex,
|
||||
WholeWord,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { EditorState } from '@codemirror/state';
|
||||
import type { ViewUpdate } from '@codemirror/view';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const MAX_MATCH_COUNT = 999;
|
||||
|
||||
// =============================================================================
|
||||
// SearchToggleButton
|
||||
// =============================================================================
|
||||
|
||||
interface SearchToggleButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
tooltip: string;
|
||||
shortcut?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const 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>
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Match counter
|
||||
// =============================================================================
|
||||
|
||||
function countMatches(query: SearchQuery, state: EditorState): number {
|
||||
if (!query.valid || !query.search) return 0;
|
||||
|
||||
try {
|
||||
const cursor = query.getCursor(state);
|
||||
let count = 0;
|
||||
while (!cursor.next().done) {
|
||||
count++;
|
||||
if (count > MAX_MATCH_COUNT) return -1;
|
||||
}
|
||||
return count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EditorSearchPanelContent
|
||||
// =============================================================================
|
||||
|
||||
interface EditorSearchPanelContentProps {
|
||||
view: EditorView;
|
||||
initialSearch: string;
|
||||
initialReplace: string;
|
||||
initialCaseSensitive: boolean;
|
||||
initialRegexp: boolean;
|
||||
initialWholeWord: boolean;
|
||||
registerUpdateNotifier: (cb: () => void) => void;
|
||||
}
|
||||
|
||||
const EditorSearchPanelContent = ({
|
||||
view,
|
||||
initialSearch,
|
||||
initialReplace,
|
||||
initialCaseSensitive,
|
||||
initialRegexp,
|
||||
initialWholeWord,
|
||||
registerUpdateNotifier,
|
||||
}: EditorSearchPanelContentProps) => {
|
||||
const [searchText, setSearchText] = useState(initialSearch);
|
||||
const [replaceText, setReplaceText] = useState(initialReplace);
|
||||
const [caseSensitive, setCaseSensitive] = useState(initialCaseSensitive);
|
||||
const [useRegexp, setUseRegexp] = useState(initialRegexp);
|
||||
const [wholeWord, setWholeWord] = useState(initialWholeWord);
|
||||
const [showReplace, setShowReplace] = useState(false);
|
||||
const [updateTick, setUpdateTick] = useState(0);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus search input on mount
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Build query object (memoized)
|
||||
const query = useMemo(
|
||||
() =>
|
||||
new SearchQuery({
|
||||
search: searchText,
|
||||
replace: replaceText,
|
||||
caseSensitive,
|
||||
regexp: useRegexp,
|
||||
wholeWord,
|
||||
}),
|
||||
[searchText, replaceText, caseSensitive, useRegexp, wholeWord]
|
||||
);
|
||||
|
||||
// Dispatch search query to CodeMirror for highlighting
|
||||
useEffect(() => {
|
||||
view.dispatch({ effects: setSearchQuery.of(query) });
|
||||
}, [query, view]);
|
||||
|
||||
// Register for editor updates (doc changes → recount via updateTick)
|
||||
useEffect(() => {
|
||||
registerUpdateNotifier(() => setUpdateTick((t) => t + 1));
|
||||
}, [registerUpdateNotifier]);
|
||||
|
||||
// Match count — derived from query + document state
|
||||
// updateTick triggers recount on document changes (e.g. after replace)
|
||||
const matchCount = useMemo(
|
||||
() => countMatches(query, view.state),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateTick is a proxy dep for view.state changes
|
||||
[query, view, updateTick]
|
||||
);
|
||||
|
||||
// Navigation
|
||||
const handleFindNext = useCallback(() => {
|
||||
findNext(view);
|
||||
}, [view]);
|
||||
|
||||
const handleFindPrev = useCallback(() => {
|
||||
findPrevious(view);
|
||||
}, [view]);
|
||||
|
||||
// Replace
|
||||
const handleReplaceNext = useCallback(() => {
|
||||
replaceNext(view);
|
||||
}, [view]);
|
||||
|
||||
const handleReplaceAll = useCallback(() => {
|
||||
replaceAll(view);
|
||||
}, [view]);
|
||||
|
||||
// Close
|
||||
const handleClose = useCallback(() => {
|
||||
closeSearchPanel(view);
|
||||
view.focus();
|
||||
}, [view]);
|
||||
|
||||
// Keyboard handlers
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
findPrevious(view);
|
||||
} else {
|
||||
findNext(view);
|
||||
}
|
||||
}
|
||||
},
|
||||
[view, handleClose]
|
||||
);
|
||||
|
||||
const handleReplaceKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleReplaceNext();
|
||||
}
|
||||
},
|
||||
[handleClose, handleReplaceNext]
|
||||
);
|
||||
|
||||
// Match count display
|
||||
const matchCountText = searchText
|
||||
? matchCount === -1
|
||||
? `${MAX_MATCH_COUNT}+`
|
||||
: matchCount === 0
|
||||
? 'No results'
|
||||
: `${matchCount} found`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="flex flex-col gap-1 px-2 py-1.5">
|
||||
{/* Search row */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Toggle replace visibility */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[22px] w-5 items-center justify-center rounded text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setShowReplace((prev) => !prev)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showReplace ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Toggle Replace</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Search input */}
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
className="h-[26px] min-w-[180px] flex-1 rounded border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-xs"
|
||||
placeholder="Search"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{/* Toggle buttons */}
|
||||
<SearchToggleButton
|
||||
active={caseSensitive}
|
||||
onClick={() => setCaseSensitive((prev) => !prev)}
|
||||
tooltip="Match Case"
|
||||
>
|
||||
<CaseSensitive className="size-[14px]" />
|
||||
</SearchToggleButton>
|
||||
|
||||
<SearchToggleButton
|
||||
active={wholeWord}
|
||||
onClick={() => setWholeWord((prev) => !prev)}
|
||||
tooltip="Match Whole Word"
|
||||
>
|
||||
<WholeWord className="size-[14px]" />
|
||||
</SearchToggleButton>
|
||||
|
||||
<SearchToggleButton
|
||||
active={useRegexp}
|
||||
onClick={() => setUseRegexp((prev) => !prev)}
|
||||
tooltip="Use Regular Expression"
|
||||
>
|
||||
<Regex className="size-[14px]" />
|
||||
</SearchToggleButton>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="mx-0.5 h-4 w-px bg-[var(--color-border)]" />
|
||||
|
||||
{/* Match count */}
|
||||
{matchCountText && (
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-[60px] whitespace-nowrap text-center text-xs tabular-nums',
|
||||
matchCount === 0 && searchText ? 'text-red-400' : 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
>
|
||||
{matchCountText}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[22px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={handleFindPrev}
|
||||
disabled={matchCount === 0}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Previous Match <span className="text-[var(--color-text-muted)]">⇧Enter</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[22px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={handleFindNext}
|
||||
disabled={matchCount === 0}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArrowDown className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Next Match <span className="text-[var(--color-text-muted)]">Enter</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Close */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[22px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={handleClose}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Close <span className="text-[var(--color-text-muted)]">Esc</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Replace row */}
|
||||
{showReplace && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Spacer to align with search input */}
|
||||
<div className="w-5 shrink-0" />
|
||||
|
||||
<Input
|
||||
className="h-[26px] min-w-[180px] flex-1 rounded border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-xs"
|
||||
placeholder="Replace"
|
||||
value={replaceText}
|
||||
onChange={(e) => setReplaceText(e.target.value)}
|
||||
onKeyDown={handleReplaceKeyDown}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-[22px] px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={handleReplaceNext}
|
||||
disabled={matchCount === 0}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Replace Next</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-[22px] px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={handleReplaceAll}
|
||||
disabled={matchCount === 0}
|
||||
tabIndex={-1}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Replace All</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Panel factory for CodeMirror
|
||||
// =============================================================================
|
||||
|
||||
export function createSearchPanel(view: EditorView): Panel {
|
||||
const dom = document.createElement('div');
|
||||
|
||||
const root = createRoot(dom);
|
||||
|
||||
// Get initial values
|
||||
const existingQuery = getSearchQuery(view.state);
|
||||
const sel = view.state.selection.main;
|
||||
const selText = sel.empty ? '' : view.state.sliceDoc(sel.from, sel.to);
|
||||
const initialSearch = selText && !selText.includes('\n') ? selText : existingQuery.search;
|
||||
|
||||
// Mutable ref for update notifications from CodeMirror
|
||||
let notifyUpdate: (() => void) | null = null;
|
||||
|
||||
root.render(
|
||||
<EditorSearchPanelContent
|
||||
view={view}
|
||||
initialSearch={initialSearch}
|
||||
initialReplace={existingQuery.replace}
|
||||
initialCaseSensitive={existingQuery.caseSensitive}
|
||||
initialRegexp={existingQuery.regexp}
|
||||
initialWholeWord={existingQuery.wholeWord}
|
||||
registerUpdateNotifier={(cb) => {
|
||||
notifyUpdate = cb;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
dom,
|
||||
top: true,
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged) {
|
||||
notifyUpdate?.();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
notifyUpdate = null;
|
||||
root.unmount();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Theme: panel container + search match highlighting
|
||||
// =============================================================================
|
||||
|
||||
export const editorSearchPanelTheme = EditorView.theme({
|
||||
'.cm-panels': {
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
},
|
||||
'.cm-panels-top': {
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
},
|
||||
'.cm-panels-bottom': {
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
},
|
||||
// Search match highlighting in editor content
|
||||
'.cm-searchMatch': {
|
||||
backgroundColor: 'var(--highlight-bg-inactive)',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
'.cm-searchMatch-selected': {
|
||||
backgroundColor: 'var(--highlight-bg) !important',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
* Uses onMouseDown preventDefault to avoid deselecting text in CM6.
|
||||
*/
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { ListTodo, MessageSquare } from 'lucide-react';
|
||||
|
||||
|
|
@ -92,16 +93,17 @@ interface MenuButtonProps {
|
|||
const MenuButton = ({ icon, label, onClick }: MenuButtonProps): React.ReactElement => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
onMouseDown={(e) => e.preventDefault()} // prevent CM6 selection loss
|
||||
className="rounded p-1.5 text-text-secondary transition-colors hover:bg-surface-raised hover:text-text"
|
||||
className="size-7 p-1.5 text-text-secondary"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
* the appropriate modifier symbols.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog';
|
||||
import { IS_MAC } from '@renderer/utils/platformKeys';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
|
|
@ -75,19 +75,6 @@ const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
|
|||
// =============================================================================
|
||||
|
||||
export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): React.ReactElement => {
|
||||
// Escape closes help (capture phase)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [onClose]);
|
||||
|
||||
// Resolve platform-specific keys once
|
||||
const resolvedGroups = useMemo(
|
||||
() =>
|
||||
|
|
@ -102,29 +89,11 @@ export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): Reac
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center" role="presentation">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shortcuts-dialog-title"
|
||||
className="relative z-10 w-[480px] rounded-lg border border-border-emphasis bg-surface p-6 shadow-2xl"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 id="shortcuts-dialog-title" className="text-sm font-semibold text-text">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="w-[480px] max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Keyboard Shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||
{resolvedGroups.map((group) => (
|
||||
|
|
@ -143,7 +112,7 @@ export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): Reac
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Status bar: cursor position, language, encoding, indent style, git branch.
|
||||
*/
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
|
||||
|
|
@ -35,9 +36,12 @@ export const EditorStatusBar = ({
|
|||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{watcherEnabled && (
|
||||
<span className="text-green-400" title="File watcher active">
|
||||
watching
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default text-green-400">watching</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">File watcher active</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span>{language}</span>
|
||||
<span>UTF-8</span>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
/**
|
||||
* Tab bar for the project editor.
|
||||
* Shows open files as tabs with dirty indicator (dot), close button,
|
||||
* and right-click context menu (close others, close to left/right, close all).
|
||||
* right-click context menu (close others, close to left/right, close all),
|
||||
* and drag-and-drop reordering via @dnd-kit.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { X } from 'lucide-react';
|
||||
|
|
@ -11,6 +24,7 @@ import { X } from 'lucide-react';
|
|||
import { EditorTabContextMenu } from './EditorTabContextMenu';
|
||||
import { FileIcon } from './FileIcon';
|
||||
|
||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import type { EditorFileTab } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -33,43 +47,91 @@ export const EditorTabBar = ({
|
|||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
|
||||
const setActiveEditorTab = useStore((s) => s.setActiveEditorTab);
|
||||
const reorderEditorTabs = useStore((s) => s.reorderEditorTabs);
|
||||
const closeOtherEditorTabs = useStore((s) => s.closeOtherEditorTabs);
|
||||
const closeEditorTabsToLeft = useStore((s) => s.closeEditorTabsToLeft);
|
||||
const closeEditorTabsToRight = useStore((s) => s.closeEditorTabsToRight);
|
||||
const closeAllEditorTabs = useStore((s) => s.closeAllEditorTabs);
|
||||
|
||||
const [draggedTab, setDraggedTab] = useState<EditorFileTab | null>(null);
|
||||
|
||||
const tabIds = useMemo(() => tabs.map((t) => t.id), [tabs]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const tab = tabs.find((t) => t.id === event.active.id);
|
||||
setDraggedTab(tab ?? null);
|
||||
},
|
||||
[tabs]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setDraggedTab(null);
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
reorderEditorTabs(String(active.id), String(over.id));
|
||||
}
|
||||
},
|
||||
[reorderEditorTabs]
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setDraggedTab(null);
|
||||
}, []);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
|
||||
role="tablist"
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
tabIndex={index}
|
||||
totalTabs={tabs.length}
|
||||
isActive={tab.id === activeTabId}
|
||||
isModified={!!modifiedFiles[tab.filePath]}
|
||||
onActivate={() => setActiveEditorTab(tab.id)}
|
||||
onRequestClose={onRequestCloseTab}
|
||||
onCloseOthers={closeOtherEditorTabs}
|
||||
onCloseToLeft={closeEditorTabsToLeft}
|
||||
onCloseToRight={closeEditorTabsToRight}
|
||||
onCloseAll={closeAllEditorTabs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
|
||||
role="tablist"
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab, index) => (
|
||||
<SortableEditorTab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
tabIndex={index}
|
||||
totalTabs={tabs.length}
|
||||
isActive={tab.id === activeTabId}
|
||||
isModified={!!modifiedFiles[tab.filePath]}
|
||||
onActivate={() => setActiveEditorTab(tab.id)}
|
||||
onRequestClose={onRequestCloseTab}
|
||||
onCloseOthers={closeOtherEditorTabs}
|
||||
onCloseToLeft={closeEditorTabsToLeft}
|
||||
onCloseToRight={closeEditorTabsToRight}
|
||||
onCloseAll={closeAllEditorTabs}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedTab && <EditorTabOverlay tab={draggedTab} />}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Tab item
|
||||
// Sortable tab item
|
||||
// =============================================================================
|
||||
|
||||
interface TabProps {
|
||||
interface SortableEditorTabProps {
|
||||
tab: EditorFileTab;
|
||||
tabIndex: number;
|
||||
totalTabs: number;
|
||||
|
|
@ -83,7 +145,7 @@ interface TabProps {
|
|||
onCloseAll: () => void;
|
||||
}
|
||||
|
||||
const Tab = ({
|
||||
const SortableEditorTab = ({
|
||||
tab,
|
||||
tabIndex,
|
||||
totalTabs,
|
||||
|
|
@ -95,7 +157,17 @@ const Tab = ({
|
|||
onCloseToLeft,
|
||||
onCloseToRight,
|
||||
onCloseAll,
|
||||
}: TabProps): React.ReactElement => {
|
||||
}: SortableEditorTabProps): React.ReactElement => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: tab.id,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: isDragging ? 'none' : transition,
|
||||
opacity: isDragging ? 0.3 : 1,
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onRequestClose(tab.id);
|
||||
|
|
@ -109,55 +181,80 @@ const Tab = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<EditorTabContextMenu
|
||||
tabId={tab.id}
|
||||
tabIndex={tabIndex}
|
||||
totalTabs={totalTabs}
|
||||
onClose={onRequestClose}
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseToLeft={onCloseToLeft}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onCloseAll={onCloseAll}
|
||||
// Sortable wrapper — must be the outermost element so @dnd-kit controls its position.
|
||||
// ContextMenu.Trigger inside EditorTabContextMenu adds an extra <div>,
|
||||
// so the useSortable ref/transform CANNOT live on the inner <button>.
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex h-full shrink-0"
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading -- @dnd-kit useSortable requires prop spreading
|
||||
{...attributes}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading -- @dnd-kit useSortable requires prop spreading
|
||||
{...listeners}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
onAuxClick={handleAuxClick}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`group flex h-full shrink-0 items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
|
||||
isActive
|
||||
? 'bg-surface text-text'
|
||||
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{isModified && (
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-400"
|
||||
aria-label="Unsaved changes"
|
||||
/>
|
||||
)}
|
||||
<FileIcon fileName={tab.fileName} className="size-3.5" />
|
||||
<span className="max-w-40 truncate">
|
||||
{tab.fileName}
|
||||
{tab.disambiguatedLabel && (
|
||||
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
onClick={handleClose}
|
||||
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
|
||||
role="button"
|
||||
aria-label={`Close ${tab.fileName}`}
|
||||
tabIndex={-1}
|
||||
<EditorTabContextMenu
|
||||
tabId={tab.id}
|
||||
tabIndex={tabIndex}
|
||||
totalTabs={totalTabs}
|
||||
onClose={onRequestClose}
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseToLeft={onCloseToLeft}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onCloseAll={onCloseAll}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
onAuxClick={handleAuxClick}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`group flex h-full shrink-0 cursor-grab items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
|
||||
isActive
|
||||
? 'bg-surface text-text'
|
||||
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
|
||||
</Tooltip>
|
||||
</EditorTabContextMenu>
|
||||
{isModified && (
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-400"
|
||||
aria-label="Unsaved changes"
|
||||
/>
|
||||
)}
|
||||
<FileIcon fileName={tab.fileName} className="size-3.5" />
|
||||
<span className="max-w-40 truncate">
|
||||
{tab.fileName}
|
||||
{tab.disambiguatedLabel && (
|
||||
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
onClick={handleClose}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
|
||||
role="button"
|
||||
aria-label={`Close ${tab.fileName}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
|
||||
</Tooltip>
|
||||
</EditorTabContextMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Drag overlay (ghost shown while dragging)
|
||||
// =============================================================================
|
||||
|
||||
const EditorTabOverlay = ({ tab }: { tab: EditorFileTab }): React.ReactElement => (
|
||||
<div className="flex items-center gap-1.5 rounded border border-border-emphasis bg-surface-raised px-3 py-1 text-xs text-text shadow-lg">
|
||||
<FileIcon fileName={tab.fileName} className="size-3.5" />
|
||||
<span className="max-w-40 truncate">{tab.fileName}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { redo, undo } from '@codemirror/commands';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
|
|
@ -96,17 +97,18 @@ const ToolbarButton = ({
|
|||
}: ToolbarButtonProps): React.ReactElement => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors hover:bg-surface-raised hover:text-text disabled:opacity-40 disabled:hover:bg-transparent ${
|
||||
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>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{label} ({shortcut})
|
||||
|
|
|
|||
186
src/renderer/components/team/editor/GoToLineDialog.tsx
Normal file
186
src/renderer/components/team/editor/GoToLineDialog.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* Go to Line dialog (Cmd+G) — custom replacement for CodeMirror's built-in gotoLine.
|
||||
*
|
||||
* Supports: line numbers, relative offsets (+5, -3), percentages (50%),
|
||||
* and optional column positions (42:10).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface GoToLineDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Line parsing
|
||||
// =============================================================================
|
||||
|
||||
interface ParsedTarget {
|
||||
line: number;
|
||||
col?: number;
|
||||
}
|
||||
|
||||
function parseLineInput(input: string, view: EditorView): ParsedTarget | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Split line:col
|
||||
const [linePart, colPart] = trimmed.split(':');
|
||||
const col = colPart ? parseInt(colPart, 10) : undefined;
|
||||
if (col !== undefined && (isNaN(col) || col < 1)) return null;
|
||||
|
||||
const lineStr = linePart.trim();
|
||||
if (!lineStr) return null;
|
||||
|
||||
const totalLines = view.state.doc.lines;
|
||||
|
||||
// Percentage: "50%"
|
||||
if (lineStr.endsWith('%')) {
|
||||
const pct = parseFloat(lineStr.slice(0, -1));
|
||||
if (isNaN(pct)) return null;
|
||||
const line = Math.max(1, Math.min(totalLines, Math.round((pct / 100) * totalLines)));
|
||||
return { line, col };
|
||||
}
|
||||
|
||||
// Relative: "+5" or "-3"
|
||||
if (lineStr.startsWith('+') || lineStr.startsWith('-')) {
|
||||
const offset = parseInt(lineStr, 10);
|
||||
if (isNaN(offset)) return null;
|
||||
const currentPos = view.state.selection.main.head;
|
||||
const currentLine = view.state.doc.lineAt(currentPos).number;
|
||||
const line = Math.max(1, Math.min(totalLines, currentLine + offset));
|
||||
return { line, col };
|
||||
}
|
||||
|
||||
// Absolute: "42"
|
||||
const lineNum = parseInt(lineStr, 10);
|
||||
if (isNaN(lineNum)) return null;
|
||||
const line = Math.max(1, Math.min(totalLines, lineNum));
|
||||
return { line, col };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const GoToLineDialog = ({ onClose }: GoToLineDialogProps): React.ReactElement => {
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Escape to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [onClose]);
|
||||
|
||||
const handleGo = useCallback(() => {
|
||||
const view = editorBridge.getView();
|
||||
if (!view) return;
|
||||
|
||||
const target = parseLineInput(value, view);
|
||||
if (!target) return;
|
||||
|
||||
const lineInfo = view.state.doc.line(target.line);
|
||||
const colOffset = target.col ? Math.min(target.col - 1, lineInfo.length) : 0;
|
||||
const pos = lineInfo.from + colOffset;
|
||||
|
||||
view.dispatch({
|
||||
selection: { anchor: pos },
|
||||
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
||||
});
|
||||
|
||||
view.focus();
|
||||
onClose();
|
||||
}, [value, onClose]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleGo();
|
||||
}
|
||||
},
|
||||
[handleGo]
|
||||
);
|
||||
|
||||
// Current line info for placeholder
|
||||
const view = editorBridge.getView();
|
||||
const totalLines = view?.state.doc.lines ?? 0;
|
||||
const currentLine = view ? view.state.doc.lineAt(view.state.selection.main.head).number : 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Go to Line"
|
||||
className="relative z-10 w-[360px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl"
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-secondary">
|
||||
Go to Line{' '}
|
||||
<span className="text-text-muted">
|
||||
(current: {currentLine}, total: {totalLines})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="h-8 flex-1 bg-transparent text-sm"
|
||||
placeholder="Line number, +offset, -offset, or %"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 px-4"
|
||||
onClick={handleGo}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
/**
|
||||
* Inline input for creating a new file or directory in the file tree.
|
||||
*
|
||||
* Auto-focuses, validates on the client side, submits on Enter, cancels on Escape/blur.
|
||||
* Auto-focuses, validates on the client side, submits on Enter, cancels on Escape.
|
||||
* Uses click-outside detection instead of onBlur for dismissal — onBlur is
|
||||
* unreliable when the input lives inside a virtualizer + DnD context + Radix
|
||||
* context menu (all of which can steal focus transiently).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
|
@ -48,19 +51,33 @@ export const NewFileDialog = ({
|
|||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track whether focus has been established (prevents premature blur cancel)
|
||||
const focusedRef = useRef(false);
|
||||
|
||||
// Focus input after Radix context menu finishes its focus restoration
|
||||
useEffect(() => {
|
||||
// Defer focus to next frame — ensures Radix context menu has fully closed
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
focusedRef.current = true;
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Click-outside → cancel (replaces unreliable onBlur)
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (e: PointerEvent): void => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
// Delay listener registration so the context menu close click isn't caught
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
}, 150);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
};
|
||||
}, [onCancel]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
const validationError = validateName(trimmed);
|
||||
|
|
@ -80,6 +97,7 @@ export const NewFileDialog = ({
|
|||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
e.stopPropagation();
|
||||
},
|
||||
[handleSubmit, onCancel]
|
||||
);
|
||||
|
|
@ -89,17 +107,10 @@ export const NewFileDialog = ({
|
|||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Only cancel if focus was already established (prevents race with RAF focus)
|
||||
if (focusedRef.current) {
|
||||
onCancel();
|
||||
}
|
||||
}, [onCancel]);
|
||||
|
||||
const Icon = type === 'file' ? FilePlus : FolderPlus;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-2 py-1">
|
||||
<div ref={containerRef} className="flex flex-col px-2 py-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="size-3.5 shrink-0 text-text-muted" />
|
||||
<input
|
||||
|
|
@ -108,7 +119,7 @@ export const NewFileDialog = ({
|
|||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onBlur={() => requestAnimationFrame(() => inputRef.current?.focus())}
|
||||
placeholder={type === 'file' ? 'File name...' : 'Folder name...'}
|
||||
className="min-w-0 flex-1 rounded border border-border-emphasis bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-blue-500"
|
||||
aria-label={type === 'file' ? 'New file name' : 'New folder name'}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardShortcuts';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
|
@ -34,6 +43,7 @@ import { EditorShortcutsHelp } from './EditorShortcutsHelp';
|
|||
import { EditorStatusBar } from './EditorStatusBar';
|
||||
import { EditorTabBar } from './EditorTabBar';
|
||||
import { EditorToolbar } from './EditorToolbar';
|
||||
import { GoToLineDialog } from './GoToLineDialog';
|
||||
import { QuickOpenDialog } from './QuickOpenDialog';
|
||||
import { SearchInFilesPanel } from './SearchInFilesPanel';
|
||||
|
||||
|
|
@ -107,6 +117,7 @@ export const ProjectEditorOverlay = ({
|
|||
// Iter-4: New state
|
||||
const [quickOpenVisible, setQuickOpenVisible] = useState(false);
|
||||
const [searchPanelVisible, setSearchPanelVisible] = useState(false);
|
||||
const [goToLineVisible, setGoToLineVisible] = useState(false);
|
||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||
const [sidebarVisible, setSidebarVisibleRaw] = useState(() => {
|
||||
try {
|
||||
|
|
@ -180,6 +191,9 @@ export const ProjectEditorOverlay = ({
|
|||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Skip if another handler already consumed this Escape
|
||||
// (e.g. CodeMirror search panel close, or React search input onKeyDown)
|
||||
if (e.defaultPrevented) return;
|
||||
// Don't close overlay if a dialog is open — dialog handles its own Escape
|
||||
if (quickOpenVisible || searchPanelVisible || shortcutsHelpVisible) return;
|
||||
if (showConfirmClose || confirmCloseTabId) return;
|
||||
|
|
@ -381,6 +395,10 @@ export const ProjectEditorOverlay = ({
|
|||
setSearchPanelVisible((v) => !v);
|
||||
}, []);
|
||||
|
||||
const toggleGoToLine = useCallback(() => {
|
||||
setGoToLineVisible((v) => !v);
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarVisibleRaw((v) => {
|
||||
const next = !v;
|
||||
|
|
@ -410,6 +428,7 @@ export const ProjectEditorOverlay = ({
|
|||
useEditorKeyboardShortcuts({
|
||||
onToggleQuickOpen: toggleQuickOpen,
|
||||
onToggleSearchPanel: toggleSearchPanel,
|
||||
onToggleGoToLine: toggleGoToLine,
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onClose: handleCloseRequest,
|
||||
});
|
||||
|
|
@ -437,35 +456,46 @@ export const ProjectEditorOverlay = ({
|
|||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-text-muted"
|
||||
onClick={handleManualRefresh}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Refresh (F5)"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Refresh git status (F5)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-text-muted"
|
||||
onClick={() => setShortcutsHelpVisible(true)}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Keyboard shortcuts</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={handleCloseRequest}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close editor"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-text-muted"
|
||||
onClick={handleCloseRequest}
|
||||
aria-label="Close editor"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Close editor (Esc)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -491,13 +521,15 @@ export const ProjectEditorOverlay = ({
|
|||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-text-muted"
|
||||
onClick={toggleSidebar}
|
||||
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Hide sidebar"
|
||||
>
|
||||
<PanelLeftClose className="size-3.5" />
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Hide sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
|
||||
|
|
@ -514,13 +546,14 @@ export const ProjectEditorOverlay = ({
|
|||
{!sidebarVisible && !searchPanelVisible && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-full w-6 shrink-0 items-start justify-center rounded-none border-r border-border bg-surface-sidebar pt-2 text-text-muted"
|
||||
onClick={toggleSidebar}
|
||||
className="flex h-full w-6 shrink-0 items-start justify-center border-r border-border bg-surface-sidebar pt-2 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Show sidebar"
|
||||
>
|
||||
<PanelLeftOpen className="size-3.5" />
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Show sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
|
||||
|
|
@ -541,18 +574,22 @@ export const ProjectEditorOverlay = ({
|
|||
<div className="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-300">
|
||||
<RotateCcw className="size-3.5 shrink-0" />
|
||||
<span>Recovered unsaved changes from a previous session.</span>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-auto px-2 py-0.5"
|
||||
onClick={handleDismissDraftBanner}
|
||||
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-auto px-2 py-0.5"
|
||||
onClick={handleDiscardDraft}
|
||||
className="rounded px-2 py-0.5 text-red-400 transition-colors hover:bg-red-400/10"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -561,12 +598,14 @@ export const ProjectEditorOverlay = ({
|
|||
<div className="flex shrink-0 items-center gap-2 border-b border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs text-red-300">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<span className="truncate">Save failed: {activeSaveError}</span>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-auto shrink-0 px-2 py-0.5"
|
||||
onClick={() => activeTabId && void saveFile(activeTabId)}
|
||||
className="ml-auto shrink-0 rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -580,26 +619,32 @@ export const ProjectEditorOverlay = ({
|
|||
: 'File changed on disk.'}
|
||||
</span>
|
||||
{externalChanges[activeTabId] === 'delete' ? (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-auto px-2 py-0.5"
|
||||
onClick={() => closeEditorTab(activeTabId)}
|
||||
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Close tab
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-auto px-2 py-0.5"
|
||||
onClick={handleReloadExternalChange}
|
||||
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-2 py-0.5"
|
||||
onClick={handleKeepMine}
|
||||
className="rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Keep mine
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -668,101 +713,80 @@ export const ProjectEditorOverlay = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Go to Line dialog */}
|
||||
{goToLineVisible && <GoToLineDialog onClose={() => setGoToLineVisible(false)} />}
|
||||
|
||||
{/* Shortcuts help modal */}
|
||||
{shortcutsHelpVisible && (
|
||||
<EditorShortcutsHelp onClose={() => setShortcutsHelpVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Unsaved changes confirmation dialog — overlay close */}
|
||||
{showConfirmClose && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Unsaved Changes</h3>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
<Dialog open={showConfirmClose} onOpenChange={(open) => !open && handleCancelClose()}>
|
||||
<DialogContent className="w-96 max-w-96">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have unsaved changes. What would you like to do?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCancelClose}
|
||||
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscardAndClose}
|
||||
className="rounded px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-400/10"
|
||||
>
|
||||
Discard & Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveAndClose()}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
Save All & Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancelClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDiscardAndClose}>
|
||||
Discard & Close
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleSaveAndClose()}>
|
||||
Save All & Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Save conflict dialog */}
|
||||
{conflictFile && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Save Conflict</h3>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
<Dialog open={!!conflictFile} onOpenChange={(open) => !open && handleCancelConflict()}>
|
||||
<DialogContent className="w-96 max-w-96">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Save Conflict</DialogTitle>
|
||||
<DialogDescription>
|
||||
The file has been modified externally since you opened it. Overwrite with your
|
||||
changes?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCancelConflict}
|
||||
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleForceOverwrite}
|
||||
className="rounded bg-orange-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-orange-500"
|
||||
>
|
||||
Overwrite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancelConflict}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleForceOverwrite}>
|
||||
Overwrite
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Unsaved changes confirmation dialog — single tab close */}
|
||||
{confirmCloseTabId && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Unsaved Changes</h3>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
<Dialog open={!!confirmCloseTabId} onOpenChange={(open) => !open && handleCancelCloseTab()}>
|
||||
<DialogContent className="w-96 max-w-96">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
This file has unsaved changes. What would you like to do?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCancelCloseTab}
|
||||
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscardAndCloseTab}
|
||||
className="rounded px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-400/10"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveAndCloseTab()}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancelCloseTab}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDiscardAndCloseTab}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleSaveAndCloseTab()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { Loader2, Search, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -154,13 +155,20 @@ export const SearchInFilesPanel = ({
|
|||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-xs font-medium text-text-secondary">Search in Files</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close search"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-text-muted"
|
||||
onClick={onClose}
|
||||
aria-label="Close search"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Close search (Esc)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,18 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { undo } from '@codemirror/commands';
|
||||
import { goToNextChunk, rejectChunk } from '@codemirror/merge';
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { EditorSelectionMenu } from '@renderer/components/team/editor/EditorSelectionMenu';
|
||||
import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav';
|
||||
import { isLastChunkInFile, useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
||||
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice';
|
||||
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
|
||||
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
|
||||
import { ChevronDown, Clock, X } from 'lucide-react';
|
||||
|
||||
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||
import { ContinuousScrollView } from './ContinuousScrollView';
|
||||
import { FileEditTimeline } from './FileEditTimeline';
|
||||
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
|
||||
|
|
@ -22,6 +25,7 @@ import { ViewedProgressBar } from './ViewedProgressBar';
|
|||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { HunkDecision, TaskChangeSetV2 } from '@shared/types';
|
||||
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
interface ChangeReviewDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -31,6 +35,8 @@ interface ChangeReviewDialogProps {
|
|||
memberName?: string;
|
||||
taskId?: string;
|
||||
initialFilePath?: string;
|
||||
projectPath?: string;
|
||||
onEditorAction?: (action: EditorSelectionAction) => void;
|
||||
}
|
||||
|
||||
function isTaskChangeSetV2(cs: { teamName: string }): cs is TaskChangeSetV2 {
|
||||
|
|
@ -45,6 +51,8 @@ export const ChangeReviewDialog = ({
|
|||
memberName,
|
||||
taskId,
|
||||
initialFilePath,
|
||||
projectPath,
|
||||
onEditorAction,
|
||||
}: ChangeReviewDialogProps): React.ReactElement | null => {
|
||||
const {
|
||||
activeChangeSet,
|
||||
|
|
@ -79,6 +87,7 @@ export const ChangeReviewDialog = ({
|
|||
pushReviewUndoSnapshot,
|
||||
undoBulkReview,
|
||||
reviewUndoStack,
|
||||
hunkContextHashesByFile,
|
||||
} = useStore();
|
||||
|
||||
// Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll)
|
||||
|
|
@ -87,6 +96,13 @@ export const ChangeReviewDialog = ({
|
|||
const [timelineOpen, setTimelineOpen] = useState(false);
|
||||
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
|
||||
|
||||
// Selection menu state
|
||||
const [selectionInfo, setSelectionInfo] = useState<EditorSelectionInfo | null>(null);
|
||||
const [containerRect, setContainerRect] = useState<DOMRect>(new DOMRect());
|
||||
const diffContentRef = useRef<HTMLDivElement>(null);
|
||||
const selectionTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const activeSelectionFileRef = useRef<string | null>(null);
|
||||
|
||||
// EditorView map for all visible file editors
|
||||
const editorViewMapRef = useRef(new Map<string, EditorView>());
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -214,9 +230,9 @@ export const ChangeReviewDialog = ({
|
|||
|
||||
const handleSaveFile = useCallback(
|
||||
(filePath: string) => {
|
||||
void saveEditedFile(filePath);
|
||||
void saveEditedFile(filePath, projectPath);
|
||||
},
|
||||
[saveEditedFile]
|
||||
[saveEditedFile, projectPath]
|
||||
);
|
||||
|
||||
const handleDiscardFile = useCallback(
|
||||
|
|
@ -242,10 +258,72 @@ export const ChangeReviewDialog = ({
|
|||
}
|
||||
}, [undoBulkReview, activeChangeSet]);
|
||||
|
||||
// Selection change handler (debounced for non-empty, immediate for clear)
|
||||
const handleSelectionChange = useCallback((info: EditorSelectionInfo | null) => {
|
||||
if (!info) {
|
||||
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
|
||||
setSelectionInfo(null);
|
||||
return;
|
||||
}
|
||||
activeSelectionFileRef.current = info.filePath;
|
||||
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
|
||||
selectionTimerRef.current = setTimeout(() => {
|
||||
setSelectionInfo(info);
|
||||
}, SELECTION_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
// Scroll repositioning — re-query coords when parent scrolls (rAF-throttled)
|
||||
const hasData = !changeSetLoading && !changeSetError && !!activeChangeSet;
|
||||
useEffect(() => {
|
||||
if (!hasData) return;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let rafId = 0;
|
||||
const onScroll = (): void => {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const fp = activeSelectionFileRef.current;
|
||||
if (!fp) return;
|
||||
const view = editorViewMapRef.current.get(fp);
|
||||
if (!view) return;
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) {
|
||||
setSelectionInfo(null);
|
||||
return;
|
||||
}
|
||||
const info = buildSelectionInfo(view, sel);
|
||||
if (info) {
|
||||
setSelectionInfo({ ...info, filePath: fp });
|
||||
} else {
|
||||
setSelectionInfo(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [hasData]);
|
||||
|
||||
// Track container rect for menu positioning
|
||||
useEffect(() => {
|
||||
const el = diffContentRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
setContainerRect(el.getBoundingClientRect());
|
||||
});
|
||||
observer.observe(el);
|
||||
setContainerRect(el.getBoundingClientRect());
|
||||
return () => observer.disconnect();
|
||||
}, [hasData]);
|
||||
|
||||
// Save active file (for Cmd+Enter keyboard shortcut)
|
||||
const handleSaveActiveFile = useCallback(() => {
|
||||
if (activeFilePath) void saveEditedFile(activeFilePath);
|
||||
}, [activeFilePath, saveEditedFile]);
|
||||
if (activeFilePath) void saveEditedFile(activeFilePath, projectPath);
|
||||
}, [activeFilePath, saveEditedFile, projectPath]);
|
||||
|
||||
// Continuous navigation options for cross-file hunk navigation
|
||||
const continuousOptions = useMemo(
|
||||
|
|
@ -268,7 +346,9 @@ export const ChangeReviewDialog = ({
|
|||
handleHunkRejected,
|
||||
() => onOpenChange(false),
|
||||
handleSaveActiveFile,
|
||||
continuousOptions
|
||||
continuousOptions,
|
||||
(filePath, fallbackSnippetsLength) =>
|
||||
getFileHunkCount(filePath, fallbackSnippetsLength, fileChunkCounts)
|
||||
);
|
||||
|
||||
// Load data on open
|
||||
|
|
@ -342,6 +422,15 @@ export const ChangeReviewDialog = ({
|
|||
});
|
||||
}, [activeChangeSet, initialFilePath, scrollToFile]);
|
||||
|
||||
// Clear selection state on close + cleanup timer
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectionInfo(null);
|
||||
activeSelectionFileRef.current = null;
|
||||
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Escape to close
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -412,20 +501,18 @@ export const ChangeReviewDialog = ({
|
|||
if (!open) return;
|
||||
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
|
||||
const fp = activeFilePathRef.current;
|
||||
const view = fp ? editorViewMapRef.current.get(fp) : null;
|
||||
if (view) {
|
||||
rejectChunk(view);
|
||||
requestAnimationFrame(() => {
|
||||
if (isLastChunkInFile(view)) {
|
||||
diffNav.goToNextFile();
|
||||
} else {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!fp) return;
|
||||
const view = editorViewMapRef.current.get(fp);
|
||||
if (!view) return;
|
||||
|
||||
const cursorPos = view.state.selection.main.head;
|
||||
const idx = computeChunkIndexAtPos(view.state, cursorPos);
|
||||
handleHunkRejected(fp, idx);
|
||||
rejectChunk(view);
|
||||
requestAnimationFrame(() => diffNav.goToNextHunk());
|
||||
});
|
||||
return cleanup ?? undefined;
|
||||
}, [open, diffNav]);
|
||||
}, [open, diffNav, handleHunkRejected]);
|
||||
|
||||
// Compute toolbar stats using actual CM chunk count (not snippet count)
|
||||
const reviewStats = useMemo(() => {
|
||||
|
|
@ -636,33 +723,54 @@ export const ChangeReviewDialog = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Continuous scroll diff content */}
|
||||
<ContinuousScrollView
|
||||
files={activeChangeSet.files}
|
||||
fileContents={fileContents}
|
||||
fileContentsLoading={fileContentsLoading}
|
||||
viewedSet={viewedSet}
|
||||
editedContents={editedContents}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
applying={applying}
|
||||
autoViewed={autoViewed}
|
||||
discardCounters={discardCounters}
|
||||
onHunkAccepted={handleHunkAccepted}
|
||||
onHunkRejected={handleHunkRejected}
|
||||
onFullyViewed={handleFullyViewed}
|
||||
onContentChanged={handleContentChanged}
|
||||
onDiscard={handleDiscardFile}
|
||||
onSave={handleSaveFile}
|
||||
onVisibleFileChange={handleVisibleFileChange}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
editorViewMapRef={editorViewMapRef}
|
||||
isProgrammaticScroll={isProgrammaticScroll}
|
||||
teamName={teamName}
|
||||
memberName={memberName}
|
||||
fetchFileContent={fetchFileContent}
|
||||
/>
|
||||
{/* Continuous scroll diff content with selection menu */}
|
||||
<div
|
||||
ref={diffContentRef}
|
||||
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<ContinuousScrollView
|
||||
files={activeChangeSet.files}
|
||||
fileContents={fileContents}
|
||||
fileContentsLoading={fileContentsLoading}
|
||||
viewedSet={viewedSet}
|
||||
editedContents={editedContents}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
hunkContextHashesByFile={hunkContextHashesByFile}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
applying={applying}
|
||||
autoViewed={autoViewed}
|
||||
discardCounters={discardCounters}
|
||||
onHunkAccepted={handleHunkAccepted}
|
||||
onHunkRejected={handleHunkRejected}
|
||||
onFullyViewed={handleFullyViewed}
|
||||
onContentChanged={handleContentChanged}
|
||||
onDiscard={handleDiscardFile}
|
||||
onSave={handleSaveFile}
|
||||
onVisibleFileChange={handleVisibleFileChange}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
editorViewMapRef={editorViewMapRef}
|
||||
isProgrammaticScroll={isProgrammaticScroll}
|
||||
teamName={teamName}
|
||||
memberName={memberName}
|
||||
fetchFileContent={fetchFileContent}
|
||||
onSelectionChange={onEditorAction ? handleSelectionChange : undefined}
|
||||
/>
|
||||
{selectionInfo && onEditorAction && (
|
||||
<EditorSelectionMenu
|
||||
info={selectionInfo}
|
||||
containerRect={containerRect}
|
||||
onSendMessage={() => {
|
||||
onEditorAction(buildSelectionAction('sendMessage', selectionInfo));
|
||||
setSelectionInfo(null);
|
||||
}}
|
||||
onCreateTask={() => {
|
||||
onEditorAction(buildSelectionAction('createTask', selectionInfo));
|
||||
setSelectionInfo(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
} from '@codemirror/merge';
|
||||
import { ChangeSet, type ChangeSpec, EditorState, type StateEffect } from '@codemirror/state';
|
||||
import { type EditorView } from '@codemirror/view';
|
||||
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||
import { structuredPatch } from 'diff';
|
||||
|
||||
/**
|
||||
* Teaches CM history to undo acceptChunk operations (updateOriginalDoc effects).
|
||||
|
|
@ -130,4 +132,116 @@ export function replayHunkDecisions(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay persisted decisions, attempting to map original hunk indices to the current
|
||||
* CodeMirror chunk indices using context hashes when available.
|
||||
*
|
||||
* Falls back to index-based replay when hashes are missing or ambiguous.
|
||||
*/
|
||||
export function replayHunkDecisionsSmart(
|
||||
view: EditorView,
|
||||
filePath: string,
|
||||
hunkDecisions: Record<string, string>,
|
||||
hunkContextHashes?: Record<number, string>
|
||||
): void {
|
||||
const result = getChunks(view.state);
|
||||
if (!result || result.chunks.length === 0) return;
|
||||
|
||||
const chunkCount = result.chunks.length;
|
||||
|
||||
// Build current hunk hash -> indices map (only if we can build a patch that matches chunk count)
|
||||
let hashToIndices: Map<string, number[]> | null = null;
|
||||
if (hunkContextHashes && Object.keys(hunkContextHashes).length > 0) {
|
||||
const original = getOriginalDoc(view.state).toString();
|
||||
const modified = view.state.doc.toString();
|
||||
const patch = structuredPatch('file', 'file', original, modified);
|
||||
const hunks = patch.hunks ?? [];
|
||||
if (hunks.length === chunkCount) {
|
||||
hashToIndices = new Map<string, number[]>();
|
||||
for (let i = 0; i < hunks.length; i++) {
|
||||
const hunk = hunks[i];
|
||||
const oldSideContent = hunk.lines
|
||||
.filter((l) => !l.startsWith('+'))
|
||||
.map((l) => l.slice(1))
|
||||
.join('\n');
|
||||
const newSideContent = hunk.lines
|
||||
.filter((l) => !l.startsWith('-'))
|
||||
.map((l) => l.slice(1))
|
||||
.join('\n');
|
||||
const hash = computeDiffContextHash(oldSideContent, newSideContent);
|
||||
const arr = hashToIndices.get(hash);
|
||||
if (arr) arr.push(i);
|
||||
else hashToIndices.set(hash, [i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all decided indices from the decision map (don't assume contiguous 0..N)
|
||||
const prefix = `${filePath}:`;
|
||||
const decided: { mappedIndex: number; decision: 'accepted' | 'rejected' }[] = [];
|
||||
const usedMapped = new Set<number>();
|
||||
|
||||
for (const [key, val] of Object.entries(hunkDecisions)) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
if (val !== 'accepted' && val !== 'rejected') continue;
|
||||
const raw = key.slice(prefix.length);
|
||||
const origIndex = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(origIndex)) continue;
|
||||
|
||||
let mappedIndex = origIndex;
|
||||
const hash = hunkContextHashes?.[origIndex];
|
||||
if (hash && hashToIndices) {
|
||||
const candidates = hashToIndices.get(hash);
|
||||
if (candidates?.length === 1) {
|
||||
mappedIndex = candidates[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedIndex < 0 || mappedIndex >= chunkCount) continue;
|
||||
if (usedMapped.has(mappedIndex)) continue;
|
||||
usedMapped.add(mappedIndex);
|
||||
decided.push({ mappedIndex, decision: val });
|
||||
}
|
||||
|
||||
if (decided.length === 0) return;
|
||||
|
||||
// Replay from later to earlier indices so chunk removals don't shift earlier ones.
|
||||
decided.sort((a, b) => b.mappedIndex - a.mappedIndex);
|
||||
|
||||
for (const { mappedIndex, decision } of decided) {
|
||||
const currentChunks = getChunks(view.state);
|
||||
if (!currentChunks || mappedIndex >= currentChunks.chunks.length) continue;
|
||||
const chunk = currentChunks.chunks[mappedIndex];
|
||||
if (decision === 'accepted') {
|
||||
acceptChunk(view, chunk.fromB);
|
||||
} else {
|
||||
rejectChunk(view, chunk.fromB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the chunk index at a given position in the modified document (B-side).
|
||||
* Returns the index of the chunk containing pos, or the nearest chunk when pos is outside.
|
||||
*/
|
||||
export function computeChunkIndexAtPos(state: EditorState, pos: number): number {
|
||||
const chunks = getChunks(state);
|
||||
if (!chunks || chunks.chunks.length === 0) return 0;
|
||||
|
||||
let nearestIndex = 0;
|
||||
let nearestDist = Infinity;
|
||||
|
||||
for (let i = 0; i < chunks.chunks.length; i++) {
|
||||
const chunk = chunks.chunks[i];
|
||||
if (pos >= chunk.fromB && pos <= chunk.toB) return i;
|
||||
const dist = Math.min(Math.abs(pos - chunk.fromB), Math.abs(pos - chunk.toB));
|
||||
if (dist < nearestDist) {
|
||||
nearestDist = dist;
|
||||
nearestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return nearestIndex;
|
||||
}
|
||||
|
||||
export { acceptChunk, getChunks, rejectChunk };
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
getAsyncLanguageDesc,
|
||||
getSyncLanguageExtension,
|
||||
} from '@renderer/utils/codemirrorLanguages';
|
||||
import { buildSelectionInfo } from '@renderer/utils/codemirrorSelectionInfo';
|
||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||
|
||||
import {
|
||||
|
|
@ -21,6 +22,8 @@ import {
|
|||
} from './CodeMirrorDiffUtils';
|
||||
import { portionCollapseExtension } from './portionCollapse';
|
||||
|
||||
import type { EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
interface CodeMirrorDiffViewProps {
|
||||
original: string;
|
||||
modified: string;
|
||||
|
|
@ -46,6 +49,8 @@ interface CodeMirrorDiffViewProps {
|
|||
usePortionCollapse?: boolean;
|
||||
/** Lines per "Expand N" click (only with usePortionCollapse). Default: 100 */
|
||||
portionSize?: number;
|
||||
/** Called when text selection changes (for floating action menu) */
|
||||
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
||||
}
|
||||
|
||||
/** Compute hunk index for the chunk at a given position (B-side / modified doc).
|
||||
|
|
@ -162,6 +167,17 @@ const diffSpecificTheme = EditorView.theme({
|
|||
},
|
||||
});
|
||||
|
||||
/** When original is empty (all additions), avoid showing a stray "deleted" block at the top. */
|
||||
const emptyOriginalOverrideTheme = EditorView.theme({
|
||||
'.cm-deletedChunk': {
|
||||
backgroundColor: 'transparent !important',
|
||||
paddingLeft: '0 !important',
|
||||
},
|
||||
'.cm-deletedLine': {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
});
|
||||
|
||||
export const CodeMirrorDiffView = ({
|
||||
original,
|
||||
modified,
|
||||
|
|
@ -180,6 +196,7 @@ export const CodeMirrorDiffView = ({
|
|||
initialState,
|
||||
usePortionCollapse = false,
|
||||
portionSize = 100,
|
||||
onSelectionChange,
|
||||
}: CodeMirrorDiffViewProps): React.ReactElement => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
|
@ -192,14 +209,23 @@ export const CodeMirrorDiffView = ({
|
|||
const onRejectRef = useRef(onHunkRejected);
|
||||
const onContentChangedRef = useRef(onContentChanged);
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
onAcceptRef.current = onHunkAccepted;
|
||||
onRejectRef.current = onHunkRejected;
|
||||
onContentChangedRef.current = onContentChanged;
|
||||
onViewChangeRef.current = onViewChange;
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
externalViewRefHolder.current = externalViewRef;
|
||||
}, [onHunkAccepted, onHunkRejected, onContentChanged, onViewChange, externalViewRef]);
|
||||
}, [
|
||||
onHunkAccepted,
|
||||
onHunkRejected,
|
||||
onContentChanged,
|
||||
onViewChange,
|
||||
onSelectionChange,
|
||||
externalViewRef,
|
||||
]);
|
||||
|
||||
// Auto-scroll to next chunk after accept/reject (deferred to let CM recalculate)
|
||||
const scrollToNextChunk = useCallback(() => {
|
||||
|
|
@ -358,6 +384,7 @@ export const CodeMirrorDiffView = ({
|
|||
const extensions: Extension[] = [
|
||||
baseEditorTheme,
|
||||
diffSpecificTheme,
|
||||
...(original.length === 0 ? [emptyOriginalOverrideTheme] : []),
|
||||
lineNumbers(),
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
EditorView.editable.of(!readOnly),
|
||||
|
|
@ -408,6 +435,20 @@ export const CodeMirrorDiffView = ({
|
|||
);
|
||||
}
|
||||
|
||||
// Selection change listener (for floating action menu)
|
||||
extensions.push(
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const sel = update.state.selection.main;
|
||||
if (sel.empty) {
|
||||
onSelectionChangeRef.current?.(null);
|
||||
} else {
|
||||
onSelectionChangeRef.current?.(buildSelectionInfo(update.view, sel));
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
|
||||
if (showMergeControls) {
|
||||
// Helper: position a chunkButtons container so it's below the change block,
|
||||
|
|
@ -566,7 +607,7 @@ export const CodeMirrorDiffView = ({
|
|||
);
|
||||
|
||||
return extensions;
|
||||
}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize]);
|
||||
}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize, original]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
acceptAllChunks,
|
||||
getChunks,
|
||||
rejectAllChunks,
|
||||
replayHunkDecisions,
|
||||
replayHunkDecisionsSmart,
|
||||
} from './CodeMirrorDiffUtils';
|
||||
import { FileSectionDiff } from './FileSectionDiff';
|
||||
import { FileSectionHeader } from './FileSectionHeader';
|
||||
|
|
@ -16,6 +16,7 @@ import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
|||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
||||
import type { EditorSelectionInfo } from '@shared/types/editor';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
interface ContinuousScrollViewProps {
|
||||
|
|
@ -26,6 +27,7 @@ interface ContinuousScrollViewProps {
|
|||
editedContents: Record<string, string>;
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
hunkContextHashesByFile: Record<string, Record<number, string>>;
|
||||
collapseUnchanged: boolean;
|
||||
applying: boolean;
|
||||
autoViewed: boolean;
|
||||
|
|
@ -47,6 +49,7 @@ interface ContinuousScrollViewProps {
|
|||
memberName: string | undefined,
|
||||
filePath: string
|
||||
) => Promise<void>;
|
||||
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
||||
}
|
||||
|
||||
export const ContinuousScrollView = ({
|
||||
|
|
@ -57,6 +60,7 @@ export const ContinuousScrollView = ({
|
|||
editedContents,
|
||||
hunkDecisions,
|
||||
fileDecisions,
|
||||
hunkContextHashesByFile,
|
||||
collapseUnchanged,
|
||||
applying,
|
||||
autoViewed,
|
||||
|
|
@ -74,6 +78,7 @@ export const ContinuousScrollView = ({
|
|||
teamName,
|
||||
memberName,
|
||||
fetchFileContent,
|
||||
onSelectionChange,
|
||||
}: ContinuousScrollViewProps): React.ReactElement => {
|
||||
const setFileChunkCount = useStore((s) => s.setFileChunkCount);
|
||||
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
|
||||
|
|
@ -126,9 +131,11 @@ export const ContinuousScrollView = ({
|
|||
// Refs to avoid stale closures — decisions change frequently
|
||||
const fileDecisionsRef = useRef(fileDecisions);
|
||||
const hunkDecisionsRef = useRef(hunkDecisions);
|
||||
const hunkHashesRef = useRef(hunkContextHashesByFile);
|
||||
useEffect(() => {
|
||||
fileDecisionsRef.current = fileDecisions;
|
||||
hunkDecisionsRef.current = hunkDecisions;
|
||||
hunkHashesRef.current = hunkContextHashesByFile;
|
||||
});
|
||||
|
||||
// Track which views have already had decisions replayed to prevent
|
||||
|
|
@ -166,7 +173,12 @@ export const ContinuousScrollView = ({
|
|||
} else {
|
||||
// Replay individual per-hunk decisions persisted from previous session
|
||||
requestAnimationFrame(() => {
|
||||
replayHunkDecisions(view, filePath, hunkDecisionsRef.current);
|
||||
replayHunkDecisionsSmart(
|
||||
view,
|
||||
filePath,
|
||||
hunkDecisionsRef.current,
|
||||
hunkHashesRef.current[filePath]
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -227,6 +239,7 @@ export const ContinuousScrollView = ({
|
|||
discardCounter={discardCounters[filePath] ?? 0}
|
||||
autoViewed={autoViewed}
|
||||
isViewed={isViewed}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
) : (
|
||||
<FileSectionPlaceholder fileName={file.relativePath} />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ReviewDiffContent } from './ReviewDiffContent';
|
|||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { FileChangeWithContent } from '@shared/types';
|
||||
import type { EditorSelectionInfo } from '@shared/types/editor';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
interface FileSectionDiffProps {
|
||||
|
|
@ -22,6 +23,7 @@ interface FileSectionDiffProps {
|
|||
discardCounter: number;
|
||||
autoViewed: boolean;
|
||||
isViewed: boolean;
|
||||
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
||||
}
|
||||
|
||||
export const FileSectionDiff = ({
|
||||
|
|
@ -37,6 +39,7 @@ export const FileSectionDiff = ({
|
|||
discardCounter,
|
||||
autoViewed,
|
||||
isViewed,
|
||||
onSelectionChange,
|
||||
}: FileSectionDiffProps): React.ReactElement => {
|
||||
const localEditorViewRef = useRef<EditorView | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -89,11 +92,16 @@ export const FileSectionDiff = ({
|
|||
return writeSnippets[writeSnippets.length - 1].newString;
|
||||
})();
|
||||
|
||||
// Show CodeMirror whenever we have resolved modified content (including new files
|
||||
// where contentSource may be 'unavailable' but write-new snippet provides the content)
|
||||
const hasCodeMirrorContent = resolvedModified !== null;
|
||||
const resolvedOriginal = fileContent?.originalFullContent ?? null;
|
||||
|
||||
if (!hasCodeMirrorContent) {
|
||||
// Show CodeMirror only when we have a trustworthy original baseline:
|
||||
// - new files: original is legitimately empty
|
||||
// - otherwise: original must be known (non-null). If original is unknown, do not
|
||||
// pretend it's empty — fall back to snippet-level diff.
|
||||
const canRenderCodeMirror =
|
||||
resolvedModified !== null && (file.isNewFile || resolvedOriginal !== null);
|
||||
|
||||
if (!canRenderCodeMirror) {
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<ReviewDiffContent file={file} />
|
||||
|
|
@ -102,16 +110,18 @@ export const FileSectionDiff = ({
|
|||
);
|
||||
}
|
||||
|
||||
const originalForDiff = file.isNewFile ? '' : (resolvedOriginal ?? '');
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<DiffErrorBoundary
|
||||
filePath={file.filePath}
|
||||
oldString={fileContent?.originalFullContent ?? ''}
|
||||
oldString={originalForDiff}
|
||||
newString={resolvedModified}
|
||||
>
|
||||
<CodeMirrorDiffView
|
||||
key={`${file.filePath}:${discardCounter}`}
|
||||
original={fileContent?.originalFullContent ?? ''}
|
||||
original={originalForDiff}
|
||||
modified={resolvedModified}
|
||||
fileName={file.relativePath}
|
||||
readOnly={false}
|
||||
|
|
@ -123,6 +133,11 @@ export const FileSectionDiff = ({
|
|||
onContentChanged={(content) => onContentChanged(file.filePath, content)}
|
||||
editorViewRef={localEditorViewRef}
|
||||
onViewChange={handleViewChange}
|
||||
onSelectionChange={
|
||||
onSelectionChange
|
||||
? (info) => onSelectionChange(info ? { ...info, filePath: file.filePath } : null)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</DiffErrorBoundary>
|
||||
<div ref={sentinelRef} className="h-1 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -1,25 +1,80 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||
import { diffLines } from 'diff';
|
||||
|
||||
import type { FileChangeSummary, SnippetDiff } from '@shared/types/review';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface ReviewDiffContentProps {
|
||||
file: FileChangeSummary;
|
||||
}
|
||||
|
||||
const SnippetDiffView = ({ snippet, index }: { snippet: SnippetDiff; index: number }) => {
|
||||
const diffResult = useMemo(() => {
|
||||
if (snippet.type === 'write-new') {
|
||||
// Весь файл — новый
|
||||
return diffLines('', snippet.newString);
|
||||
interface DiffLine {
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
html: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/** Build highlighted diff lines by mapping diff parts onto pre-highlighted old/new lines. */
|
||||
function buildHighlightedDiffLines(snippet: SnippetDiff, fileName: string): DiffLine[] {
|
||||
const isFullNew = snippet.type === 'write-new' || snippet.type === 'write-update';
|
||||
const oldCode = isFullNew ? '' : snippet.oldString;
|
||||
const diffResult = diffLines(oldCode, snippet.newString);
|
||||
|
||||
const oldHighlighted = highlightLines(oldCode, fileName);
|
||||
const newHighlighted = highlightLines(snippet.newString, fileName);
|
||||
|
||||
const result: DiffLine[] = [];
|
||||
let oldIdx = 0;
|
||||
let newIdx = 0;
|
||||
|
||||
for (const part of diffResult) {
|
||||
const lineCount = part.value.replace(/\n$/, '').split('\n').length;
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
if (part.removed) {
|
||||
result.push({
|
||||
type: 'removed',
|
||||
html: oldHighlighted[oldIdx++] ?? '',
|
||||
});
|
||||
} else if (part.added) {
|
||||
result.push({
|
||||
type: 'added',
|
||||
html: newHighlighted[newIdx++] ?? '',
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: 'unchanged',
|
||||
html: oldHighlighted[oldIdx++] ?? '',
|
||||
});
|
||||
newIdx++;
|
||||
}
|
||||
}
|
||||
if (snippet.type === 'write-update') {
|
||||
// Полная перезапись
|
||||
return diffLines('', snippet.newString);
|
||||
}
|
||||
return diffLines(snippet.oldString, snippet.newString);
|
||||
}, [snippet]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SnippetDiffView
|
||||
// =============================================================================
|
||||
|
||||
const SnippetDiffView = ({
|
||||
snippet,
|
||||
index,
|
||||
fileName,
|
||||
}: {
|
||||
snippet: SnippetDiff;
|
||||
index: number;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const lines = useMemo(() => buildHighlightedDiffLines(snippet, fileName), [snippet, fileName]);
|
||||
|
||||
const toolLabel =
|
||||
snippet.type === 'write-new'
|
||||
|
|
@ -32,7 +87,7 @@ const SnippetDiffView = ({ snippet, index }: { snippet: SnippetDiff; index: numb
|
|||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{/* Заголовок snippet */}
|
||||
{/* Snippet header */}
|
||||
<div className="flex items-center justify-between border-b border-border bg-surface-raised px-3 py-1.5">
|
||||
<span className="text-xs text-text-muted">
|
||||
#{index + 1} {toolLabel}
|
||||
|
|
@ -42,48 +97,54 @@ const SnippetDiffView = ({ snippet, index }: { snippet: SnippetDiff; index: numb
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Строки диффа */}
|
||||
{/* Diff lines with syntax highlighting (hljs HTML — safe, all input is escaped) */}
|
||||
<div className="overflow-x-auto font-mono text-xs leading-5">
|
||||
{diffResult.map((part, i) => {
|
||||
const lines = part.value.replace(/\n$/, '').split('\n');
|
||||
return lines.map((line, j) => {
|
||||
let bgClass = '';
|
||||
let prefix = ' ';
|
||||
let textClass = 'text-text-secondary';
|
||||
{lines.map((line, i) => {
|
||||
let bgClass = '';
|
||||
let prefix = ' ';
|
||||
|
||||
if (part.added) {
|
||||
bgClass = 'bg-[var(--diff-added-bg,rgba(46,160,67,0.15))]';
|
||||
prefix = '+';
|
||||
textClass = 'text-[var(--diff-added-text,#3fb950)]';
|
||||
} else if (part.removed) {
|
||||
bgClass = 'bg-[var(--diff-removed-bg,rgba(248,81,73,0.15))]';
|
||||
prefix = '-';
|
||||
textClass = 'text-[var(--diff-removed-text,#f85149)]';
|
||||
}
|
||||
if (line.type === 'added') {
|
||||
bgClass = 'bg-[var(--diff-added-bg,rgba(46,160,67,0.15))]';
|
||||
prefix = '+';
|
||||
} else if (line.type === 'removed') {
|
||||
bgClass = 'bg-[var(--diff-removed-bg,rgba(248,81,73,0.15))]';
|
||||
prefix = '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${i}-${j}`} className={`px-3 ${bgClass} ${textClass}`}>
|
||||
<span className="inline-block w-4 select-none text-text-muted opacity-50">
|
||||
{prefix}
|
||||
</span>
|
||||
<span className="whitespace-pre">{line}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div key={i} className={`flex px-3 ${bgClass}`}>
|
||||
<span className="inline-block w-4 shrink-0 select-none text-text-muted opacity-50">
|
||||
{prefix}
|
||||
</span>
|
||||
{/* highlight.js escapes all input text — only produces <span class="hljs-*"> tags */}
|
||||
<span
|
||||
className="whitespace-pre text-text-secondary"
|
||||
dangerouslySetInnerHTML={{ __html: line.html }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ReviewDiffContent
|
||||
// =============================================================================
|
||||
|
||||
export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => {
|
||||
const nonErrorSnippets = useMemo(() => file.snippets.filter((s) => !s.isError), [file.snippets]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* Snippets */}
|
||||
{nonErrorSnippets.map((snippet, index) => (
|
||||
<SnippetDiffView key={snippet.toolUseId} snippet={snippet} index={index} />
|
||||
<SnippetDiffView
|
||||
key={snippet.toolUseId}
|
||||
snippet={snippet}
|
||||
index={index}
|
||||
fileName={file.relativePath}
|
||||
/>
|
||||
))}
|
||||
|
||||
{nonErrorSnippets.length === 0 && (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
|
||||
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
|
||||
import {
|
||||
computeChunkIndexAtPos,
|
||||
getChunks,
|
||||
} from '@renderer/components/team/review/CodeMirrorDiffUtils';
|
||||
import { physicalKey } from '@renderer/utils/keyboardUtils';
|
||||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
|
|
@ -99,7 +102,8 @@ export function useDiffNavigation(
|
|||
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
|
||||
onClose?: () => void,
|
||||
onSaveFile?: () => void,
|
||||
continuousOptions?: ContinuousNavigationOptions
|
||||
continuousOptions?: ContinuousNavigationOptions,
|
||||
getHunkCountForFile?: (filePath: string, fallbackSnippetsLength: number) => number
|
||||
): DiffNavigationState {
|
||||
const [hunkState, setHunkState] = useState<{ filePath: string | null; index: number }>({
|
||||
filePath: selectedFilePath,
|
||||
|
|
@ -109,7 +113,10 @@ export function useDiffNavigation(
|
|||
|
||||
const activePath = getActiveFilePath(selectedFilePath, continuousOptions);
|
||||
const selectedFile = files.find((f) => f.filePath === activePath);
|
||||
const totalHunks = selectedFile?.snippets.length ?? 0;
|
||||
const totalHunks =
|
||||
selectedFile && getHunkCountForFile
|
||||
? getHunkCountForFile(selectedFile.filePath, selectedFile.snippets.length)
|
||||
: (selectedFile?.snippets.length ?? 0);
|
||||
|
||||
const currentHunkIndex = hunkState.filePath === activePath ? hunkState.index : 0;
|
||||
|
||||
|
|
@ -320,14 +327,14 @@ export function useDiffNavigation(
|
|||
event.preventDefault();
|
||||
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
|
||||
if (view) {
|
||||
const filePath = getActiveFilePath(selectedFilePath, continuousOptionsRef.current);
|
||||
if (filePath && onHunkAccepted) {
|
||||
const cursorPos = view.state.selection.main.head;
|
||||
const idx = computeChunkIndexAtPos(view.state, cursorPos);
|
||||
onHunkAccepted(filePath, idx);
|
||||
}
|
||||
acceptChunk(view);
|
||||
requestAnimationFrame(() => {
|
||||
if (continuousOptionsRef.current?.enabled && isLastChunkInFile(view)) {
|
||||
goToNextFile();
|
||||
} else {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
});
|
||||
requestAnimationFrame(() => goToNextHunk());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { gotoLine, openSearchPanel } from '@codemirror/search';
|
||||
import { openSearchPanel } from '@codemirror/search';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
import { physicalKey } from '@renderer/utils/keyboardUtils';
|
||||
|
|
@ -21,6 +21,7 @@ import type { EditorFileTab } from '@shared/types/editor';
|
|||
interface UseEditorKeyboardShortcutsOptions {
|
||||
onToggleQuickOpen: () => void;
|
||||
onToggleSearchPanel: () => void;
|
||||
onToggleGoToLine: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
|
@ -35,6 +36,7 @@ export interface EditorKeyHandlerDeps {
|
|||
hasUnsavedChanges: () => boolean;
|
||||
onToggleQuickOpen: () => void;
|
||||
onToggleSearchPanel: () => void;
|
||||
onToggleGoToLine: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
onToggleLineWrap: () => void;
|
||||
getEditorView: () => { dispatch: unknown } | null;
|
||||
|
|
@ -85,8 +87,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
|||
if (key === 'g' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const view = deps.getEditorView();
|
||||
if (view) gotoLine(view as Parameters<typeof gotoLine>[0]);
|
||||
deps.onToggleGoToLine();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +186,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
|||
export function useEditorKeyboardShortcuts({
|
||||
onToggleQuickOpen,
|
||||
onToggleSearchPanel,
|
||||
onToggleGoToLine,
|
||||
onToggleSidebar,
|
||||
onClose: _onClose,
|
||||
}: UseEditorKeyboardShortcutsOptions): void {
|
||||
|
|
@ -207,6 +209,7 @@ export function useEditorKeyboardShortcuts({
|
|||
hasUnsavedChanges,
|
||||
onToggleQuickOpen,
|
||||
onToggleSearchPanel,
|
||||
onToggleGoToLine,
|
||||
onToggleSidebar,
|
||||
onToggleLineWrap: toggleLineWrap,
|
||||
getEditorView: () => editorBridge.getView(),
|
||||
|
|
@ -222,6 +225,7 @@ export function useEditorKeyboardShortcuts({
|
|||
hasUnsavedChanges,
|
||||
onToggleQuickOpen,
|
||||
onToggleSearchPanel,
|
||||
onToggleGoToLine,
|
||||
onToggleSidebar,
|
||||
toggleLineWrap,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { api } from '@renderer/api';
|
||||
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { structuredPatch } from 'diff';
|
||||
|
||||
/** Tracks in-flight checkTaskHasChanges calls to avoid duplicate requests */
|
||||
const taskChangesCheckInFlight = new Set<string>();
|
||||
|
|
@ -63,6 +65,8 @@ export interface ChangeReviewSlice {
|
|||
fileChunkCounts: Record<string, number>;
|
||||
/** Undo stack for bulk review operations (Accept All / Reject All) */
|
||||
reviewUndoStack: DecisionSnapshot[];
|
||||
/** filePath -> (hunkIndex -> contextHash), persisted for robust replay */
|
||||
hunkContextHashesByFile: Record<string, Record<number, string>>;
|
||||
fileContents: Record<string, FileChangeWithContent>;
|
||||
fileContentsLoading: Record<string, boolean>;
|
||||
collapseUnchanged: boolean;
|
||||
|
|
@ -118,7 +122,7 @@ export interface ChangeReviewSlice {
|
|||
updateEditedContent: (filePath: string, content: string) => void;
|
||||
discardFileEdits: (filePath: string) => void;
|
||||
discardAllEdits: () => void;
|
||||
saveEditedFile: (filePath: string) => Promise<void>;
|
||||
saveEditedFile: (filePath: string, projectPath?: string) => Promise<void>;
|
||||
|
||||
// Task change availability
|
||||
checkTaskHasChanges: (teamName: string, taskId: string) => Promise<void>;
|
||||
|
|
@ -164,6 +168,52 @@ export function getFileHunkCount(
|
|||
return fileChunkCounts[filePath] ?? snippetsLength;
|
||||
}
|
||||
|
||||
function getMaxDecisionIndexForFile(
|
||||
filePath: string,
|
||||
hunkDecisions: Record<string, HunkDecision>
|
||||
): number {
|
||||
let max = -1;
|
||||
const prefix = `${filePath}:`;
|
||||
for (const key of Object.keys(hunkDecisions)) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
const raw = key.slice(prefix.length);
|
||||
const idx = Number.parseInt(raw, 10);
|
||||
if (!Number.isNaN(idx)) {
|
||||
max = Math.max(max, idx);
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function buildHunkContextHashesForFile(
|
||||
original: string | null | undefined,
|
||||
modified: string | null | undefined,
|
||||
expectedHunkCount: number
|
||||
): Record<number, string> | undefined {
|
||||
if (original === null || original === undefined) return undefined;
|
||||
if (modified === null || modified === undefined) return undefined;
|
||||
|
||||
const patch = structuredPatch('file', 'file', original, modified);
|
||||
const hunks = patch.hunks ?? [];
|
||||
if (hunks.length === 0) return undefined;
|
||||
if (hunks.length !== expectedHunkCount) return undefined;
|
||||
|
||||
const out: Record<number, string> = {};
|
||||
for (let i = 0; i < hunks.length; i++) {
|
||||
const hunk = hunks[i];
|
||||
const oldSideContent = hunk.lines
|
||||
.filter((l) => !l.startsWith('+'))
|
||||
.map((l) => l.slice(1))
|
||||
.join('\n');
|
||||
const newSideContent = hunk.lines
|
||||
.filter((l) => !l.startsWith('-'))
|
||||
.map((l) => l.slice(1))
|
||||
.join('\n');
|
||||
out[i] = computeDiffContextHash(oldSideContent, newSideContent);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
|
||||
set,
|
||||
get
|
||||
|
|
@ -180,6 +230,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
fileDecisions: {},
|
||||
fileChunkCounts: {},
|
||||
reviewUndoStack: [],
|
||||
hunkContextHashesByFile: {},
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
collapseUnchanged: true,
|
||||
|
|
@ -239,6 +290,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
fileDecisions: {},
|
||||
fileChunkCounts: {},
|
||||
reviewUndoStack: [],
|
||||
hunkContextHashesByFile: {},
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
applyError: null,
|
||||
|
|
@ -255,6 +307,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
selectedReviewFilePath: null,
|
||||
fileChunkCounts: {},
|
||||
reviewUndoStack: [],
|
||||
hunkContextHashesByFile: {},
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
applyError: null,
|
||||
|
|
@ -273,6 +326,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
fileDecisions: {},
|
||||
fileChunkCounts: {},
|
||||
reviewUndoStack: [],
|
||||
hunkContextHashesByFile: {},
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
applyError: null,
|
||||
|
|
@ -291,6 +345,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
set({
|
||||
hunkDecisions: data?.hunkDecisions ?? {},
|
||||
fileDecisions: data?.fileDecisions ?? {},
|
||||
hunkContextHashesByFile: data?.hunkContextHashesByFile ?? {},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('loadDecisionsFromDisk error:', error);
|
||||
|
|
@ -302,8 +357,40 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
clearTimeout(persistDebounceTimer);
|
||||
}
|
||||
persistDebounceTimer = setTimeout(() => {
|
||||
const { hunkDecisions, fileDecisions } = get();
|
||||
void api.review.saveDecisions(teamName, scopeKey, hunkDecisions, fileDecisions);
|
||||
const {
|
||||
hunkDecisions,
|
||||
fileDecisions,
|
||||
hunkContextHashesByFile,
|
||||
activeChangeSet,
|
||||
fileContents,
|
||||
fileChunkCounts,
|
||||
} = get();
|
||||
|
||||
const computed: Record<string, Record<number, string>> = {};
|
||||
for (const file of activeChangeSet?.files ?? []) {
|
||||
const fp = file.filePath;
|
||||
const content = fileContents[fp];
|
||||
if (!content) continue;
|
||||
const expected = getFileHunkCount(fp, file.snippets.length, fileChunkCounts);
|
||||
const hashes = buildHunkContextHashesForFile(
|
||||
content.originalFullContent,
|
||||
content.modifiedFullContent,
|
||||
expected
|
||||
);
|
||||
if (hashes) computed[fp] = hashes;
|
||||
}
|
||||
|
||||
// Prune to only files in the current scope. This avoids persisting stale file paths
|
||||
// (e.g. from older sessions) that could confuse future replays.
|
||||
const mergedHashes: Record<string, Record<number, string>> = {};
|
||||
for (const file of activeChangeSet?.files ?? []) {
|
||||
const fp = file.filePath;
|
||||
mergedHashes[fp] = computed[fp] ?? hunkContextHashesByFile[fp] ?? {};
|
||||
}
|
||||
// Keep store in sync so replay can use hashes without reload.
|
||||
set({ hunkContextHashesByFile: mergedHashes });
|
||||
|
||||
void api.review.saveDecisions(teamName, scopeKey, hunkDecisions, fileDecisions, mergedHashes);
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
},
|
||||
|
||||
|
|
@ -540,7 +627,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
}
|
||||
|
||||
// Build FileReviewDecision[] from hunkDecisions/fileDecisions
|
||||
const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet } = get();
|
||||
const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet, fileContents } =
|
||||
get();
|
||||
if (!activeChangeSet) {
|
||||
set({ applying: false });
|
||||
return;
|
||||
|
|
@ -552,7 +640,9 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
const fileDecision = fileDecisions[file.filePath] ?? 'pending';
|
||||
const hunkDecs: Record<number, HunkDecision> = {};
|
||||
|
||||
const count = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
|
||||
const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
|
||||
const maxIdx = getMaxDecisionIndexForFile(file.filePath, hunkDecisions);
|
||||
const count = Math.max(baseCount, maxIdx + 1);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = `${file.filePath}:${i}`;
|
||||
hunkDecs[i] = hunkDecisions[key] ?? 'pending';
|
||||
|
|
@ -562,10 +652,26 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
const hasRejected =
|
||||
fileDecision === 'rejected' || Object.values(hunkDecs).some((d) => d === 'rejected');
|
||||
if (hasRejected) {
|
||||
const content = fileContents[file.filePath];
|
||||
const hunkContextHashes =
|
||||
maxIdx < baseCount
|
||||
? buildHunkContextHashesForFile(
|
||||
content?.originalFullContent,
|
||||
content?.modifiedFullContent,
|
||||
baseCount
|
||||
)
|
||||
: undefined;
|
||||
decisions.push({
|
||||
filePath: file.filePath,
|
||||
fileDecision,
|
||||
hunkDecisions: hunkDecs,
|
||||
hunkContextHashes,
|
||||
// Provide optional context so main can apply without re-resolving.
|
||||
// If full contents are missing (lazy not loaded yet), still pass snippets.
|
||||
snippets: content?.snippets ?? file.snippets,
|
||||
originalFullContent: content?.originalFullContent,
|
||||
modifiedFullContent: content?.modifiedFullContent,
|
||||
isNewFile: content?.isNewFile ?? file.isNewFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -600,7 +706,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
taskId?: string,
|
||||
memberName?: string
|
||||
) => {
|
||||
const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet } = get();
|
||||
const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet, fileContents } = get();
|
||||
if (!activeChangeSet) return;
|
||||
|
||||
const file = activeChangeSet.files.find((f) => f.filePath === filePath);
|
||||
|
|
@ -608,7 +714,9 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
|
||||
const fileDecision = fileDecisions[filePath] ?? 'pending';
|
||||
const hunkDecs: Record<number, HunkDecision> = {};
|
||||
const count = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts);
|
||||
const baseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts);
|
||||
const maxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions);
|
||||
const count = Math.max(baseCount, maxIdx + 1);
|
||||
for (let i = 0; i < count; i++) {
|
||||
hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending';
|
||||
}
|
||||
|
|
@ -618,11 +726,33 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
if (!hasRejected) return;
|
||||
|
||||
try {
|
||||
const content = fileContents[filePath];
|
||||
const baseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts);
|
||||
const maxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions);
|
||||
const hunkContextHashes =
|
||||
maxIdx < baseCount
|
||||
? buildHunkContextHashesForFile(
|
||||
content?.originalFullContent,
|
||||
content?.modifiedFullContent,
|
||||
baseCount
|
||||
)
|
||||
: undefined;
|
||||
await api.review.applyDecisions({
|
||||
teamName,
|
||||
taskId,
|
||||
memberName,
|
||||
decisions: [{ filePath, fileDecision, hunkDecisions: hunkDecs }],
|
||||
decisions: [
|
||||
{
|
||||
filePath,
|
||||
fileDecision,
|
||||
hunkDecisions: hunkDecs,
|
||||
hunkContextHashes,
|
||||
snippets: content?.snippets ?? file.snippets,
|
||||
originalFullContent: content?.originalFullContent,
|
||||
modifiedFullContent: content?.modifiedFullContent,
|
||||
isNewFile: content?.isNewFile ?? file.isNewFile,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('applySingleFileDecision error:', error);
|
||||
|
|
@ -648,12 +778,12 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
|
||||
discardAllEdits: () => set({ editedContents: {} }),
|
||||
|
||||
saveEditedFile: async (filePath: string) => {
|
||||
saveEditedFile: async (filePath: string, projectPath?: string) => {
|
||||
const content = get().editedContents[filePath];
|
||||
if (!(filePath in get().editedContents)) return;
|
||||
set({ applying: true, applyError: null });
|
||||
try {
|
||||
await api.review.saveEditedFile(filePath, content);
|
||||
await api.review.saveEditedFile(filePath, content, projectPath);
|
||||
set((s) => {
|
||||
const nextEdited = { ...s.editedContents };
|
||||
delete nextEdited[filePath];
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export interface EditorSlice {
|
|||
closeEditorTabsToRight: (tabId: string) => void;
|
||||
closeAllEditorTabs: () => void;
|
||||
setActiveEditorTab: (tabId: string) => void;
|
||||
reorderEditorTabs: (activeId: string, overId: string) => void;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 3: Content + Save
|
||||
|
|
@ -111,6 +112,7 @@ export interface EditorSlice {
|
|||
createDirInTree: (parentDir: string, dirName: string) => Promise<string | null>;
|
||||
deleteFileFromTree: (filePath: string) => Promise<boolean>;
|
||||
moveFileInTree: (sourcePath: string, destDir: string) => Promise<boolean>;
|
||||
renameFileInTree: (sourcePath: string, newName: string) => Promise<boolean>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 5: Git status + file watcher + line wrap (iter-5)
|
||||
|
|
@ -427,6 +429,19 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
set({ editorActiveTabId: tabId });
|
||||
},
|
||||
|
||||
reorderEditorTabs: (activeId: string, overId: string) => {
|
||||
if (activeId === overId) return;
|
||||
const { editorOpenTabs } = get();
|
||||
const oldIndex = editorOpenTabs.findIndex((t) => t.id === activeId);
|
||||
const newIndex = editorOpenTabs.findIndex((t) => t.id === overId);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const updated = [...editorOpenTabs];
|
||||
const [moved] = updated.splice(oldIndex, 1);
|
||||
updated.splice(newIndex, 0, moved);
|
||||
set({ editorOpenTabs: updated });
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 3: Content + Save
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
|
@ -739,6 +754,99 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
}
|
||||
},
|
||||
|
||||
renameFileInTree: async (sourcePath: string, newName: string) => {
|
||||
const { editorSaving } = get();
|
||||
|
||||
if (editorSaving[sourcePath]) {
|
||||
log.error('renameFileInTree: blocked — file is being saved:', sourcePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.editor.renameFile(sourcePath, newName);
|
||||
const { newPath, isDirectory } = result;
|
||||
const parentDir = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
|
||||
|
||||
recentMoveTimestamps.set(sourcePath, Date.now());
|
||||
recentMoveTimestamps.set(newPath, Date.now());
|
||||
|
||||
set((s) => {
|
||||
const tabs = s.editorOpenTabs.map((tab) => {
|
||||
const remapped = remapPath(tab.filePath, sourcePath, newPath);
|
||||
if (remapped === tab.filePath) return tab;
|
||||
const fileName = remapped.split('/').pop() ?? 'file';
|
||||
return {
|
||||
...tab,
|
||||
id: remapped,
|
||||
filePath: remapped,
|
||||
fileName,
|
||||
language: getLanguageFromFileName(fileName),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
editorOpenTabs: computeDisambiguatedTabs(tabs),
|
||||
editorActiveTabId:
|
||||
remapPath(s.editorActiveTabId ?? '', sourcePath, newPath) || s.editorActiveTabId,
|
||||
editorModifiedFiles: remapRecord(s.editorModifiedFiles, sourcePath, newPath),
|
||||
editorSaving: remapRecord(s.editorSaving, sourcePath, newPath),
|
||||
editorSaveError: remapRecord(s.editorSaveError, sourcePath, newPath),
|
||||
editorFileLoading: remapRecord(s.editorFileLoading, sourcePath, newPath),
|
||||
editorExternalChanges: remapRecord(s.editorExternalChanges, sourcePath, newPath),
|
||||
editorFileMtimes: remapRecord(s.editorFileMtimes, sourcePath, newPath),
|
||||
editorExpandedDirs: remapRecord(s.editorExpandedDirs, sourcePath, newPath),
|
||||
};
|
||||
});
|
||||
|
||||
// Remap bridge state
|
||||
const { editorOpenTabs } = get();
|
||||
for (const tab of editorOpenTabs) {
|
||||
const originalPath = reverseRemapPath(tab.filePath, sourcePath, newPath);
|
||||
if (originalPath !== tab.filePath) {
|
||||
editorBridge.remapState(originalPath, tab.filePath);
|
||||
}
|
||||
}
|
||||
if (!isDirectory) {
|
||||
editorBridge.remapState(sourcePath, newPath);
|
||||
}
|
||||
|
||||
// Remap localStorage drafts
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith('editor-draft:')) {
|
||||
const draftPath = key.slice('editor-draft:'.length);
|
||||
const remapped = remapPath(draftPath, sourcePath, newPath);
|
||||
if (remapped !== draftPath) {
|
||||
const value = localStorage.getItem(key);
|
||||
localStorage.removeItem(key);
|
||||
if (value !== null) localStorage.setItem(`editor-draft:${remapped}`, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage may not be available
|
||||
}
|
||||
|
||||
for (const [key, ts] of [...recentSaveTimestamps.entries()]) {
|
||||
const remapped = remapPath(key, sourcePath, newPath);
|
||||
if (remapped !== key) {
|
||||
recentSaveTimestamps.delete(key);
|
||||
recentSaveTimestamps.set(remapped, ts);
|
||||
}
|
||||
}
|
||||
|
||||
void refreshDirectory(get, set, parentDir);
|
||||
void get().fetchGitStatus();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error('renameFileInTree failed:', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 5: Git status + file watcher + line wrap
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -50,14 +50,20 @@ const notifiedClarificationTaskKeys = new Set<string>();
|
|||
|
||||
let isFirstFetchAllTasks = true;
|
||||
|
||||
function detectClarificationNotifications(oldTasks: GlobalTask[], newTasks: GlobalTask[]): void {
|
||||
function detectClarificationNotifications(
|
||||
oldTasks: GlobalTask[],
|
||||
newTasks: GlobalTask[],
|
||||
notifyEnabled: boolean
|
||||
): void {
|
||||
for (const task of newTasks) {
|
||||
const key = `${task.teamName}:${task.id}`;
|
||||
if (task.needsClarification === 'user') {
|
||||
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
|
||||
if (oldTask?.needsClarification !== 'user' && !notifiedClarificationTaskKeys.has(key)) {
|
||||
notifiedClarificationTaskKeys.add(key);
|
||||
fireClarificationNotification(task);
|
||||
if (notifyEnabled) {
|
||||
fireClarificationNotification(task);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notifiedClarificationTaskKeys.delete(key);
|
||||
|
|
@ -283,7 +289,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
const tasks = await unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks());
|
||||
|
||||
if (!wasFirst) {
|
||||
detectClarificationNotifications(oldTasks, tasks);
|
||||
const notifyOnClarifications =
|
||||
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
||||
detectClarificationNotifications(oldTasks, tasks, notifyOnClarifications);
|
||||
} else {
|
||||
// Initial load — seed the Set to prevent false notifications on next update
|
||||
for (const task of tasks) {
|
||||
|
|
|
|||
41
src/renderer/utils/codemirrorSelectionInfo.ts
Normal file
41
src/renderer/utils/codemirrorSelectionInfo.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Shared utility for building EditorSelectionInfo from a CodeMirror EditorView.
|
||||
*
|
||||
* Used by both CodeMirrorEditor (project editor) and CodeMirrorDiffView (review dialog)
|
||||
* to extract selection details for the floating action menu.
|
||||
*/
|
||||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
export const SELECTION_DEBOUNCE_MS = 150;
|
||||
export const MAX_SELECTION_TEXT = 5000;
|
||||
|
||||
/**
|
||||
* Build selection info from a CM6 EditorView and selection range.
|
||||
* Returns null if selection end is off-screen (coordsAtPos returns null).
|
||||
*/
|
||||
export function buildSelectionInfo(
|
||||
view: EditorView,
|
||||
sel: { from: number; to: number }
|
||||
): EditorSelectionInfo | null {
|
||||
const coords = view.coordsAtPos(sel.to);
|
||||
if (!coords) return null;
|
||||
|
||||
let text = view.state.sliceDoc(sel.from, sel.to);
|
||||
if (text.length > MAX_SELECTION_TEXT) {
|
||||
text = text.slice(0, MAX_SELECTION_TEXT) + '\u2026';
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
filePath: '', // filled by caller
|
||||
fromLine: view.state.doc.lineAt(sel.from).number,
|
||||
toLine: view.state.doc.lineAt(sel.to).number,
|
||||
screenRect: {
|
||||
top: coords.top,
|
||||
right: coords.right ?? coords.left,
|
||||
bottom: coords.bottom,
|
||||
},
|
||||
};
|
||||
}
|
||||
158
src/renderer/utils/syntaxHighlighter.ts
Normal file
158
src/renderer/utils/syntaxHighlighter.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* Standalone syntax highlighter using highlight.js.
|
||||
*
|
||||
* Highlights code without a full CodeMirror EditorView.
|
||||
* Outputs HTML strings with `hljs-*` CSS classes (already styled in index.css).
|
||||
*/
|
||||
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
// =============================================================================
|
||||
// File extension → highlight.js language mapping
|
||||
// =============================================================================
|
||||
|
||||
const EXT_TO_LANG: Record<string, string> = {
|
||||
'.js': 'javascript',
|
||||
'.mjs': 'javascript',
|
||||
'.cjs': 'javascript',
|
||||
'.jsx': 'javascript',
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'typescript',
|
||||
'.py': 'python',
|
||||
'.rb': 'ruby',
|
||||
'.go': 'go',
|
||||
'.rs': 'rust',
|
||||
'.java': 'java',
|
||||
'.kt': 'kotlin',
|
||||
'.swift': 'swift',
|
||||
'.c': 'c',
|
||||
'.cpp': 'cpp',
|
||||
'.cc': 'cpp',
|
||||
'.h': 'c',
|
||||
'.hpp': 'cpp',
|
||||
'.cs': 'csharp',
|
||||
'.php': 'php',
|
||||
'.sh': 'bash',
|
||||
'.bash': 'bash',
|
||||
'.zsh': 'bash',
|
||||
'.json': 'json',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.xml': 'xml',
|
||||
'.html': 'xml',
|
||||
'.htm': 'xml',
|
||||
'.css': 'css',
|
||||
'.scss': 'scss',
|
||||
'.less': 'less',
|
||||
'.sql': 'sql',
|
||||
'.md': 'markdown',
|
||||
'.toml': 'ini',
|
||||
'.ini': 'ini',
|
||||
'.lua': 'lua',
|
||||
'.r': 'r',
|
||||
'.scala': 'scala',
|
||||
'.dart': 'dart',
|
||||
'.ex': 'elixir',
|
||||
'.exs': 'elixir',
|
||||
'.erl': 'erlang',
|
||||
'.hs': 'haskell',
|
||||
'.pl': 'perl',
|
||||
'.pm': 'perl',
|
||||
'.m': 'objectivec',
|
||||
'.mm': 'objectivec',
|
||||
'.makefile': 'makefile',
|
||||
'.cmake': 'cmake',
|
||||
'.dockerfile': 'dockerfile',
|
||||
'.tf': 'hcl',
|
||||
'.proto': 'protobuf',
|
||||
'.graphql': 'graphql',
|
||||
'.gql': 'graphql',
|
||||
'.vue': 'xml',
|
||||
'.svelte': 'xml',
|
||||
};
|
||||
|
||||
function getLanguage(fileName: string): string | undefined {
|
||||
const dotIndex = fileName.lastIndexOf('.');
|
||||
if (dotIndex === -1) return undefined;
|
||||
const ext = fileName.slice(dotIndex).toLowerCase();
|
||||
|
||||
// Explicit map first, then try extension as hljs alias (e.g. 'rb', 'py')
|
||||
const mapped = EXT_TO_LANG[ext];
|
||||
if (mapped) return mapped;
|
||||
|
||||
const bare = ext.slice(1); // '.ts' → 'ts'
|
||||
if (bare && hljs.getLanguage(bare)) return bare;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTML line splitting
|
||||
// =============================================================================
|
||||
|
||||
/** Escape HTML and split into plain-text lines (fallback for unknown languages). */
|
||||
function escapeAndSplit(code: string): string[] {
|
||||
const escaped = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return escaped.split('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Split highlight.js HTML output into per-line strings with balanced tags.
|
||||
* Multi-line spans (comments, strings) are properly closed/reopened at line breaks.
|
||||
*/
|
||||
function splitHtmlByLines(html: string): string[] {
|
||||
const rawLines = html.split('\n');
|
||||
const result: string[] = [];
|
||||
const openTags: string[] = [];
|
||||
|
||||
for (const rawLine of rawLines) {
|
||||
// Prefix with any spans still open from previous lines
|
||||
const prefix = openTags.join('');
|
||||
const fullLine = prefix + rawLine;
|
||||
|
||||
// Update open tags stack by scanning this line's tags
|
||||
const tagRegex = /<span[^>]*>|<\/span>/g;
|
||||
let match;
|
||||
while ((match = tagRegex.exec(rawLine)) !== null) {
|
||||
if (match[0] === '</span>') {
|
||||
if (openTags.length > 0) openTags.pop();
|
||||
} else {
|
||||
openTags.push(match[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Close any unclosed spans for this line
|
||||
const suffix = '</span>'.repeat(openTags.length);
|
||||
result.push(fullLine + suffix);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public API
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Highlight code and return per-line HTML strings with `hljs-*` CSS classes.
|
||||
* Uses highlight.js (same library as rehype-highlight in markdown rendering).
|
||||
*/
|
||||
export function highlightLines(code: string, fileName: string): string[] {
|
||||
if (!code) return [''];
|
||||
|
||||
const lang = getLanguage(fileName);
|
||||
|
||||
let highlighted: string;
|
||||
if (!lang) {
|
||||
// Unknown extension — plain text is safer than unreliable auto-detection
|
||||
return escapeAndSplit(code);
|
||||
}
|
||||
|
||||
try {
|
||||
highlighted = hljs.highlight(code, { language: lang }).value;
|
||||
} catch {
|
||||
return escapeAndSplit(code);
|
||||
}
|
||||
|
||||
return splitHtmlByLines(highlighted);
|
||||
}
|
||||
|
|
@ -489,7 +489,11 @@ export interface ReviewAPI {
|
|||
snippets: SnippetDiff[]
|
||||
) => Promise<{ preview: string; hasConflicts: boolean }>;
|
||||
// Editable diff
|
||||
saveEditedFile: (filePath: string, content: string) => Promise<{ success: boolean }>;
|
||||
saveEditedFile: (
|
||||
filePath: string,
|
||||
content: string,
|
||||
projectPath?: string
|
||||
) => Promise<{ success: boolean }>;
|
||||
// Decision persistence
|
||||
loadDecisions: (
|
||||
teamName: string,
|
||||
|
|
@ -497,12 +501,18 @@ export interface ReviewAPI {
|
|||
) => Promise<{
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
/**
|
||||
* Optional stable hunk fingerprints persisted from the renderer.
|
||||
* filePath -> (hunkIndex -> contextHash)
|
||||
*/
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
} | null>;
|
||||
saveDecisions: (
|
||||
teamName: string,
|
||||
scopeKey: string,
|
||||
hunkDecisions: Record<string, HunkDecision>,
|
||||
fileDecisions: Record<string, HunkDecision>
|
||||
fileDecisions: Record<string, HunkDecision>,
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>
|
||||
) => Promise<void>;
|
||||
clearDecisions: (teamName: string, scopeKey: string) => Promise<void>;
|
||||
onCmdN?: (callback: () => void) => (() => void) | undefined;
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ export interface EditorAPI {
|
|||
createDir: (parentDir: string, dirName: string) => Promise<CreateDirResponse>;
|
||||
deleteFile: (filePath: string) => Promise<DeleteFileResponse>;
|
||||
moveFile: (sourcePath: string, destDir: string) => Promise<MoveFileResponse>;
|
||||
renameFile: (sourcePath: string, newName: string) => Promise<MoveFileResponse>;
|
||||
searchInFiles: (options: SearchInFilesOptions) => Promise<SearchInFilesResult>;
|
||||
listFiles: () => Promise<QuickOpenFile[]>;
|
||||
gitStatus: () => Promise<GitStatusResult>;
|
||||
|
|
|
|||
|
|
@ -247,6 +247,10 @@ export interface AppConfig {
|
|||
snoozeMinutes: number;
|
||||
/** Whether to include errors from subagent sessions */
|
||||
includeSubagentErrors: boolean;
|
||||
/** Whether to show native OS notifications for team inbox messages */
|
||||
notifyOnInboxMessages: boolean;
|
||||
/** Whether to show native OS notifications when a task needs user clarification */
|
||||
notifyOnClarifications: boolean;
|
||||
/** Notification triggers - define when to generate notifications */
|
||||
triggers: NotificationTrigger[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -81,6 +81,16 @@ export interface FileReviewDecision {
|
|||
filePath: string;
|
||||
fileDecision: HunkDecision;
|
||||
hunkDecisions: Record<number, HunkDecision>;
|
||||
/** Optional stable hunk fingerprints (index → contextHash). Used to map decisions when indices drift. */
|
||||
hunkContextHashes?: Record<number, string>;
|
||||
/**
|
||||
* Optional context to apply decisions without re-resolving content in main process.
|
||||
* When present, main can use these values directly (safer in task mode where memberName may be unknown).
|
||||
*/
|
||||
snippets?: SnippetDiff[];
|
||||
originalFullContent?: string | null;
|
||||
modifiedFullContent?: string | null;
|
||||
isNewFile?: boolean;
|
||||
}
|
||||
|
||||
/** Запрос на применение review */
|
||||
|
|
|
|||
22
src/shared/utils/diffContextHash.ts
Normal file
22
src/shared/utils/diffContextHash.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Computes a stable, lightweight hash for diff context matching.
|
||||
*
|
||||
* This is intentionally NON-cryptographic and designed for:
|
||||
* - matching hunks/snippets across processes
|
||||
* - tolerating small differences by using head/tail windows
|
||||
*/
|
||||
export function computeDiffContextHash(oldString: string, newString: string): string {
|
||||
const take3 = (s: string): string => {
|
||||
const lines = s.split('\n');
|
||||
const head = lines.slice(0, 3).join('\n');
|
||||
const tail = lines.length > 3 ? lines.slice(-3).join('\n') : '';
|
||||
return `${head}|${tail}`;
|
||||
};
|
||||
const raw = `${take3(oldString)}::${take3(newString)}`;
|
||||
// DJB2 variant
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
hash = ((hash << 5) + hash + raw.charCodeAt(i)) | 0;
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
|||
EDITOR_CREATE_DIR: 'editor:createDir',
|
||||
EDITOR_DELETE_FILE: 'editor:deleteFile',
|
||||
EDITOR_MOVE_FILE: 'editor:moveFile',
|
||||
EDITOR_RENAME_FILE: 'editor:renameFile',
|
||||
EDITOR_SEARCH_IN_FILES: 'editor:searchInFiles',
|
||||
EDITOR_LIST_FILES: 'editor:listFiles',
|
||||
EDITOR_GIT_STATUS: 'editor:gitStatus',
|
||||
|
|
@ -145,8 +146,8 @@ describe('Editor IPC handlers', () => {
|
|||
});
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers all 13 editor channels', () => {
|
||||
expect(mockIpc.handle).toHaveBeenCalledTimes(13);
|
||||
it('registers all 14 editor channels', () => {
|
||||
expect(mockIpc.handle).toHaveBeenCalledTimes(14);
|
||||
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:readDir')).toBe(true);
|
||||
|
|
@ -156,6 +157,7 @@ describe('Editor IPC handlers', () => {
|
|||
expect(mockIpc._handlers.has('editor:createDir')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:deleteFile')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:moveFile')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:renameFile')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:searchInFiles')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:listFiles')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:gitStatus')).toBe(true);
|
||||
|
|
@ -164,7 +166,7 @@ describe('Editor IPC handlers', () => {
|
|||
|
||||
it('removeEditorHandlers clears all channels', () => {
|
||||
removeEditorHandlers(mockIpc as unknown as IpcMain);
|
||||
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(13);
|
||||
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(14);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
38
test/main/services/team/ReviewApplierService.test.ts
Normal file
38
test/main/services/team/ReviewApplierService.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { structuredPatch } from 'diff';
|
||||
|
||||
import type { SnippetDiff } from '@shared/types';
|
||||
|
||||
describe('ReviewApplierService', () => {
|
||||
it('previewReject avoids write-update snippet-level replacement', async () => {
|
||||
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
||||
const original = 'hello\nworld\n';
|
||||
const modified = 'HELLO\nworld\n';
|
||||
|
||||
// Sanity: ensure there is at least one hunk for this change
|
||||
const patch = structuredPatch('file', 'file', original, modified);
|
||||
expect(patch.hunks.length).toBeGreaterThan(0);
|
||||
|
||||
const snippets: SnippetDiff[] = [
|
||||
{
|
||||
toolUseId: 't1',
|
||||
filePath: '/tmp/file.txt',
|
||||
toolName: 'Write',
|
||||
type: 'write-update',
|
||||
oldString: '',
|
||||
newString: modified, // full file write
|
||||
replaceAll: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
isError: false,
|
||||
},
|
||||
];
|
||||
|
||||
const svc = new ReviewApplierService();
|
||||
|
||||
// Preview should restore original content (and must not collapse to empty due to write-update).
|
||||
const preview = await svc.previewReject('/tmp/file.txt', original, modified, [0], snippets);
|
||||
expect(preview.hasConflicts).toBe(false);
|
||||
expect(preview.preview).toBe(original);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,13 +5,12 @@
|
|||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock @codemirror/search — handler calls openSearchPanel/gotoLine when view exists
|
||||
// Mock @codemirror/search — handler calls openSearchPanel when view exists
|
||||
vi.mock('@codemirror/search', () => ({
|
||||
openSearchPanel: vi.fn(),
|
||||
gotoLine: vi.fn(),
|
||||
}));
|
||||
|
||||
import { gotoLine, openSearchPanel } from '@codemirror/search';
|
||||
import { openSearchPanel } from '@codemirror/search';
|
||||
import { createEditorKeyHandler } from '@renderer/hooks/useEditorKeyboardShortcuts';
|
||||
|
||||
import type { EditorKeyHandlerDeps } from '@renderer/hooks/useEditorKeyboardShortcuts';
|
||||
|
|
@ -50,6 +49,7 @@ function createMockDeps(overrides: Partial<EditorKeyHandlerDeps> = {}): EditorKe
|
|||
hasUnsavedChanges: vi.fn().mockReturnValue(false),
|
||||
onToggleQuickOpen: vi.fn(),
|
||||
onToggleSearchPanel: vi.fn(),
|
||||
onToggleGoToLine: vi.fn(),
|
||||
onToggleSidebar: vi.fn(),
|
||||
onToggleLineWrap: vi.fn(),
|
||||
getEditorView: vi.fn().mockReturnValue(null),
|
||||
|
|
@ -144,12 +144,10 @@ describe('createEditorKeyHandler', () => {
|
|||
});
|
||||
|
||||
describe('Cmd+G — Go to Line', () => {
|
||||
it('calls gotoLine when editor view exists', () => {
|
||||
const mockView = { dispatch: vi.fn() };
|
||||
deps = createMockDeps({ getEditorView: vi.fn().mockReturnValue(mockView) });
|
||||
it('calls onToggleGoToLine', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('g'));
|
||||
expect(gotoLine).toHaveBeenCalledWith(mockView);
|
||||
expect(deps.onToggleGoToLine).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue