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