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:
iliya 2026-03-01 17:52:54 +02:00
parent cb8017b0db
commit f4f02d5536
55 changed files with 2793 additions and 535 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

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

View file

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

View file

@ -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', () => {

View file

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

View file

@ -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 ──
/**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -287,6 +287,8 @@ export function useSettingsHandlers({
snoozedUntil: null,
snoozeMinutes: 30,
includeSubagentErrors: true,
notifyOnInboxMessages: true,
notifyOnClarifications: true,
triggers: defaultTriggers,
},
general: {

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

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

View file

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

View file

@ -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 &ldquo;{deleteConfirmPath?.split('/').pop() ?? ''}&rdquo; 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
// =============================================================================

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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>
);
};

View file

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

View file

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

View file

@ -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 */}

View file

@ -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>
</>
)}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
},
};
}

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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);
}

View file

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

View file

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

View file

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

View file

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

View 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);
}

View file

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

View 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);
});
});

View file

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