commit
d946531871
154 changed files with 12669 additions and 1569 deletions
31
README.md
31
README.md
|
|
@ -19,6 +19,37 @@
|
|||
|
||||
<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
|
||||
- **Live process dashboard** — see which agents are running processes in dedicated sections (frontend, backend, etc.) and open URLs directly in the browser
|
||||
- **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
|
||||
|
||||
<details>
|
||||
<summary><strong>More features</strong></summary>
|
||||
|
||||
<br />
|
||||
|
||||
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
|
||||
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
|
||||
- **Smart task-to-log matching** — automatically links Claude session logs to specific tasks based on status change timestamps, even when a task moves back and forth between states
|
||||
- **Zero-setup onboarding** — built-in Claude Code installation and authentication, ready to go out of the box
|
||||
- **Built-in code editor** — edit project files with Git support and other essential features without leaving the app
|
||||
- **Branch strategy control** — choose via prompt whether all agents work on a single branch or each gets its own git worktree
|
||||
- **Team member stats** — global performance statistics for every member of the team
|
||||
- **Attach code context** — reference files or code snippets in your messages, just like in Cursor
|
||||
- **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/2b420b2c-c4af-4d10-a679-c83269f8ee99">
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@
|
|||
"cmdk": "1.0.4",
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^8.0.3",
|
||||
"dompurify": "^3.3.1",
|
||||
"electron-updater": "^6.7.3",
|
||||
"fastify": "^5.7.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
|
@ -117,12 +118,14 @@
|
|||
"isbinaryfile": "^6.0.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"mermaid": "^11.12.3",
|
||||
"node-diff3": "^3.2.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"simple-git": "^3.32.3",
|
||||
|
|
|
|||
1031
pnpm-lock.yaml
1031
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -42,3 +42,6 @@ export const CLAUDE_WORKTREES_DIR = '.claude-worktrees';
|
|||
|
||||
/** ccswitch worktrees directory */
|
||||
export const CCSWITCH_DIR = '.ccswitch';
|
||||
|
||||
/** Claude Code CLI worktrees directory (.claude/worktrees/) */
|
||||
export const CLAUDE_CODE_DIR = '.claude';
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
* - POST /api/config/triggers/:triggerId/test - Test trigger
|
||||
* - POST /api/config/pin-session - Pin session
|
||||
* - POST /api/config/unpin-session - Unpin session
|
||||
* - POST /api/config/add-custom-project-path - Add custom project path
|
||||
* - POST /api/config/remove-custom-project-path - Remove custom project path
|
||||
* - POST /api/config/select-folders - No-op in browser
|
||||
* - POST /api/config/open-in-editor - No-op in browser
|
||||
*/
|
||||
|
|
@ -470,6 +472,44 @@ export function registerConfigRoutes(app: FastifyInstance): void {
|
|||
}
|
||||
);
|
||||
|
||||
// Add custom project path
|
||||
app.post<{ Body: { projectPath: string } }>(
|
||||
'/api/config/add-custom-project-path',
|
||||
async (request): Promise<ConfigResult> => {
|
||||
try {
|
||||
const { projectPath } = request.body;
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
return { success: false, error: 'Project path is required and must be a string' };
|
||||
}
|
||||
|
||||
configManager.addCustomProjectPath(projectPath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/config/add-custom-project-path:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Remove custom project path
|
||||
app.post<{ Body: { projectPath: string } }>(
|
||||
'/api/config/remove-custom-project-path',
|
||||
async (request): Promise<ConfigResult> => {
|
||||
try {
|
||||
const { projectPath } = request.body;
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
return { success: false, error: 'Project path is required and must be a string' };
|
||||
}
|
||||
|
||||
configManager.removeCustomProjectPath(projectPath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/config/remove-custom-project-path:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Select folders - no-op in browser mode
|
||||
app.post('/api/config/select-folders', async (): Promise<ConfigResult<string[]>> => {
|
||||
return { success: true, data: [] };
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
|||
import { showTeamNativeNotification } from './ipc/teams';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||
import { getAppIconPath } from './utils/appIcon';
|
||||
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||
import {
|
||||
CliInstallerService,
|
||||
|
|
@ -125,19 +126,89 @@ async function resolveTeamDisplayName(teamName: string): Promise<string> {
|
|||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox message types that are internal coordination noise — not useful as OS notifications.
|
||||
* Matches renderer-side NOISE_TYPES in agentMessageFormatting.ts.
|
||||
*/
|
||||
const INBOX_NOISE_TYPES = new Set([
|
||||
'idle_notification',
|
||||
'shutdown_approved',
|
||||
'teammate_terminated',
|
||||
'shutdown_request',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parses an inbox message text that may be serialized JSON.
|
||||
* Returns null if not valid JSON or not an object.
|
||||
*/
|
||||
function parseInboxJson(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{')) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// not JSON — plain text message
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns true if the inbox message text is a noise type that should not trigger an OS notification. */
|
||||
function isInboxNoiseMessage(text: string): boolean {
|
||||
const parsed = parseInboxJson(text);
|
||||
if (!parsed) return false;
|
||||
return typeof parsed.type === 'string' && INBOX_NOISE_TYPES.has(parsed.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts human-readable summary and body from an inbox message.
|
||||
* Handles both plain text and serialized JSON ({"type":"message","content":"...","summary":"..."}).
|
||||
*/
|
||||
function extractNotificationContent(text: string): { summary: string; body: string } {
|
||||
const parsed = parseInboxJson(text);
|
||||
if (!parsed) return { summary: text.slice(0, 80), body: text };
|
||||
|
||||
const content = typeof parsed.content === 'string' ? parsed.content : null;
|
||||
const summary = typeof parsed.summary === 'string' ? parsed.summary : null;
|
||||
const message = typeof parsed.message === 'string' ? parsed.message : null;
|
||||
|
||||
const bestBody = content || message || summary || text;
|
||||
const bestSummary =
|
||||
summary || (content ? content.slice(0, 80) : null) || message || text.slice(0, 80);
|
||||
|
||||
return { summary: bestSummary, body: bestBody };
|
||||
}
|
||||
|
||||
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
|
||||
// Check global toggle
|
||||
const config = configManager.getConfig();
|
||||
if (!config.notifications.enabled) return;
|
||||
|
||||
// detail is like "inboxes/carol.json" — extract member name
|
||||
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
||||
if (!match) return;
|
||||
const memberName = match[1];
|
||||
|
||||
// Determine inbox type and check per-inbox toggle
|
||||
const leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null;
|
||||
const isLeadInbox = leadName !== null && memberName === leadName;
|
||||
const isUserInbox = memberName === 'user';
|
||||
|
||||
if (isLeadInbox && !config.notifications.notifyOnLeadInbox) return;
|
||||
if (isUserInbox && !config.notifications.notifyOnUserInbox) return;
|
||||
if (!isLeadInbox && !isUserInbox) return;
|
||||
|
||||
const key = `${teamName}:${memberName}`;
|
||||
|
||||
try {
|
||||
const messages = await teamInboxReader.getMessagesFor(teamName, memberName);
|
||||
const isFirstLoad = !inboxMessageCounts.has(key);
|
||||
const prevCount = inboxMessageCounts.get(key) ?? 0;
|
||||
|
||||
if (prevCount === 0) {
|
||||
// First load — seed count, don't notify
|
||||
if (isFirstLoad) {
|
||||
// First load — seed count, don't notify for pre-existing messages
|
||||
inboxMessageCounts.set(key, messages.length);
|
||||
return;
|
||||
}
|
||||
|
|
@ -154,18 +225,19 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
const teamDisplayName = await resolveTeamDisplayName(teamName);
|
||||
|
||||
for (const msg of newMessages) {
|
||||
// Only notify for messages addressed to the human user
|
||||
if (msg.to !== 'user') continue;
|
||||
// Skip messages sent from our own UI
|
||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
||||
if (isInboxNoiseMessage(msg.text)) continue;
|
||||
|
||||
const fromLabel = msg.from || 'Unknown';
|
||||
const summary = msg.summary || msg.text.slice(0, 60);
|
||||
const extracted = extractNotificationContent(msg.text);
|
||||
const summary = msg.summary || extracted.summary;
|
||||
|
||||
showTeamNativeNotification({
|
||||
title: teamDisplayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: msg.text,
|
||||
body: extracted.body,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -173,24 +245,6 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
}
|
||||
}
|
||||
|
||||
// Window icon path for non-mac platforms.
|
||||
const getWindowIconPath = (): string | undefined => {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const candidates = isDev
|
||||
? [join(process.cwd(), 'resources/icon.png')]
|
||||
: [
|
||||
join(process.resourcesPath, 'resources/icon.png'),
|
||||
join(__dirname, '../../resources/icon.png'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('Unhandled promise rejection in main process:', reason);
|
||||
});
|
||||
|
|
@ -349,21 +403,25 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
// Show native OS notification for live lead process replies.
|
||||
// These don't go through inbox files — they're held in-memory by TeamProvisioningService.
|
||||
if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') {
|
||||
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
|
||||
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
// Only notify for messages addressed to the human user
|
||||
if (latest?.to === 'user') {
|
||||
const fromLabel = latest.from || 'team-lead';
|
||||
const summary = latest.summary || latest.text.slice(0, 60);
|
||||
void resolveTeamDisplayName(teamName)
|
||||
.then((displayName) => {
|
||||
showTeamNativeNotification({
|
||||
title: displayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: latest.text,
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
const cfg = configManager.getConfig();
|
||||
if (cfg.notifications.enabled && cfg.notifications.notifyOnUserInbox) {
|
||||
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
|
||||
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
// Only notify for messages addressed to the human user, skip noise
|
||||
if (latest?.to === 'user' && !isInboxNoiseMessage(latest.text)) {
|
||||
const fromLabel = latest.from || 'team-lead';
|
||||
const extracted = extractNotificationContent(latest.text);
|
||||
const summary = latest.summary || extracted.summary;
|
||||
void resolveTeamDisplayName(teamName)
|
||||
.then((displayName) => {
|
||||
showTeamNativeNotification({
|
||||
title: displayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: extracted.body,
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -690,7 +748,7 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
|
|||
*/
|
||||
function createWindow(): void {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const iconPath = isMac ? undefined : getWindowIconPath();
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar;
|
||||
mainWindow = new BrowserWindow({
|
||||
width: DEFAULT_WINDOW_WIDTH,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ export function registerConfigHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo);
|
||||
ipcMain.handle('config:findWslClaudeRoots', handleFindWslClaudeRoots);
|
||||
|
||||
// Custom project path handlers
|
||||
ipcMain.handle('config:addCustomProjectPath', handleAddCustomProjectPath);
|
||||
ipcMain.handle('config:removeCustomProjectPath', handleRemoveCustomProjectPath);
|
||||
|
||||
// Editor handlers
|
||||
ipcMain.handle('config:openInEditor', handleOpenInEditor);
|
||||
|
||||
|
|
@ -624,6 +628,46 @@ async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<IpcResult
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'config:addCustomProjectPath' - Persists a custom project path.
|
||||
*/
|
||||
async function handleAddCustomProjectPath(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath: string
|
||||
): Promise<IpcResult> {
|
||||
try {
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
return { success: false, error: 'Project path is required and must be a string' };
|
||||
}
|
||||
|
||||
configManager.addCustomProjectPath(projectPath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in config:addCustomProjectPath:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'config:removeCustomProjectPath' - Removes a custom project path.
|
||||
*/
|
||||
async function handleRemoveCustomProjectPath(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath: string
|
||||
): Promise<IpcResult> {
|
||||
try {
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
return { success: false, error: 'Project path is required and must be a string' };
|
||||
}
|
||||
|
||||
configManager.removeCustomProjectPath(projectPath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in config:removeCustomProjectPath:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
|
||||
* Allows users to select one or more folders for trigger project scope.
|
||||
|
|
@ -1091,6 +1135,8 @@ export function removeConfigHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler('config:unhideSession');
|
||||
ipcMain.removeHandler('config:hideSessions');
|
||||
ipcMain.removeHandler('config:unhideSessions');
|
||||
ipcMain.removeHandler('config:addCustomProjectPath');
|
||||
ipcMain.removeHandler('config:removeCustomProjectPath');
|
||||
ipcMain.removeHandler('config:selectFolders');
|
||||
ipcMain.removeHandler('config:selectClaudeRootFolder');
|
||||
ipcMain.removeHandler('config:getClaudeRootInfo');
|
||||
|
|
|
|||
|
|
@ -17,11 +17,16 @@ import {
|
|||
EDITOR_LIST_FILES,
|
||||
EDITOR_MOVE_FILE,
|
||||
EDITOR_OPEN,
|
||||
EDITOR_READ_BINARY_PREVIEW,
|
||||
EDITOR_READ_DIR,
|
||||
EDITOR_READ_FILE,
|
||||
EDITOR_RENAME_FILE,
|
||||
EDITOR_SEARCH_IN_FILES,
|
||||
EDITOR_SET_WATCHED_DIRS,
|
||||
EDITOR_SET_WATCHED_FILES,
|
||||
EDITOR_WATCH_DIR,
|
||||
EDITOR_WRITE_FILE,
|
||||
PROJECT_LIST_FILES,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -40,6 +45,7 @@ import {
|
|||
import { createIpcWrapper } from './ipcWrapper';
|
||||
|
||||
import type {
|
||||
BinaryPreviewResult,
|
||||
CreateDirResponse,
|
||||
CreateFileResponse,
|
||||
DeleteFileResponse,
|
||||
|
|
@ -244,6 +250,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).
|
||||
*/
|
||||
|
|
@ -282,6 +302,24 @@ async function handleEditorListFiles(): Promise<IpcResult<QuickOpenFile[]>> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List project files by explicit path (for @file mentions).
|
||||
* Independent of editor state — works without editor:open.
|
||||
*/
|
||||
async function handleProjectListFiles(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath: string
|
||||
): Promise<IpcResult<QuickOpenFile[]>> {
|
||||
return wrapHandler('project:listFiles', async () => {
|
||||
if (typeof projectPath !== 'string' || projectPath.length === 0) {
|
||||
throw new Error('projectPath is required');
|
||||
}
|
||||
const normalized = path.resolve(projectPath);
|
||||
await fs.access(normalized);
|
||||
return fileSearchService.listFiles(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git status for current project (cached 5s).
|
||||
*/
|
||||
|
|
@ -292,6 +330,19 @@ async function handleEditorGitStatus(): Promise<IpcResult<GitStatusResult>> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read binary file as base64 for inline preview.
|
||||
*/
|
||||
async function handleEditorReadBinaryPreview(
|
||||
_event: IpcMainInvokeEvent,
|
||||
filePath: string
|
||||
): Promise<IpcResult<BinaryPreviewResult>> {
|
||||
return wrapHandler('readBinaryPreview', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
return projectFileService.readBinaryPreview(activeProjectRoot, filePath);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable file watcher for current project.
|
||||
*/
|
||||
|
|
@ -304,8 +355,13 @@ async function handleEditorWatchDir(
|
|||
|
||||
if (enable) {
|
||||
editorFileWatcher.start(activeProjectRoot, (event) => {
|
||||
// Invalidate git cache on file changes
|
||||
gitStatusService.invalidateCache();
|
||||
// Structural changes (create/delete): immediate invalidation.
|
||||
// Content changes: debounced (500ms) to coalesce rapid saves/builds.
|
||||
if (event.type === 'create' || event.type === 'delete') {
|
||||
gitStatusService.invalidateCache();
|
||||
} else {
|
||||
gitStatusService.invalidateCacheDebounced();
|
||||
}
|
||||
|
||||
// Forward event to renderer
|
||||
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
|
||||
|
|
@ -318,6 +374,32 @@ async function handleEditorWatchDir(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watched file list (open tabs).
|
||||
*/
|
||||
async function handleEditorSetWatchedFiles(
|
||||
_event: IpcMainInvokeEvent,
|
||||
filePaths: string[]
|
||||
): Promise<IpcResult<void>> {
|
||||
return wrapHandler('setWatchedFiles', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
editorFileWatcher.setWatchedFiles(Array.isArray(filePaths) ? filePaths : []);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watched directory list (shallow, depth=0).
|
||||
*/
|
||||
async function handleEditorSetWatchedDirs(
|
||||
_event: IpcMainInvokeEvent,
|
||||
dirPaths: string[]
|
||||
): Promise<IpcResult<void>> {
|
||||
return wrapHandler('setWatchedDirs', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
editorFileWatcher.setWatchedDirs(Array.isArray(dirPaths) ? dirPaths : []);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Registration
|
||||
// =============================================================================
|
||||
|
|
@ -344,10 +426,15 @@ export function registerEditorHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(EDITOR_CREATE_DIR, handleEditorCreateDir);
|
||||
ipcMain.handle(EDITOR_DELETE_FILE, handleEditorDeleteFile);
|
||||
ipcMain.handle(EDITOR_MOVE_FILE, handleEditorMoveFile);
|
||||
ipcMain.handle(EDITOR_RENAME_FILE, handleEditorRenameFile);
|
||||
ipcMain.handle(EDITOR_SEARCH_IN_FILES, handleEditorSearchInFiles);
|
||||
ipcMain.handle(EDITOR_LIST_FILES, handleEditorListFiles);
|
||||
ipcMain.handle(EDITOR_READ_BINARY_PREVIEW, handleEditorReadBinaryPreview);
|
||||
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
|
||||
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
|
||||
ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles);
|
||||
ipcMain.handle(EDITOR_SET_WATCHED_DIRS, handleEditorSetWatchedDirs);
|
||||
ipcMain.handle(PROJECT_LIST_FILES, handleProjectListFiles);
|
||||
}
|
||||
|
||||
export function removeEditorHandlers(ipcMain: IpcMain): void {
|
||||
|
|
@ -360,10 +447,15 @@ export function removeEditorHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(EDITOR_CREATE_DIR);
|
||||
ipcMain.removeHandler(EDITOR_DELETE_FILE);
|
||||
ipcMain.removeHandler(EDITOR_MOVE_FILE);
|
||||
ipcMain.removeHandler(EDITOR_RENAME_FILE);
|
||||
ipcMain.removeHandler(EDITOR_SEARCH_IN_FILES);
|
||||
ipcMain.removeHandler(EDITOR_LIST_FILES);
|
||||
ipcMain.removeHandler(EDITOR_READ_BINARY_PREVIEW);
|
||||
ipcMain.removeHandler(EDITOR_GIT_STATUS);
|
||||
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
||||
ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES);
|
||||
ipcMain.removeHandler(EDITOR_SET_WATCHED_DIRS);
|
||||
ipcMain.removeHandler(PROJECT_LIST_FILES);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|||
const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const TEAM_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const TASK_ID_PATTERN = /^\d{1,10}$/;
|
||||
const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
|
|
|
|||
|
|
@ -221,7 +221,44 @@ async function handleApplyDecisions(
|
|||
if (!request || !Array.isArray(request.decisions)) {
|
||||
return { success: false, error: 'Invalid request: decisions array required' };
|
||||
}
|
||||
return wrapReviewHandler('applyDecisions', () => getApplier().applyReviewDecisions(request));
|
||||
return wrapReviewHandler('applyDecisions', async () => {
|
||||
// Build file contents map for the applier. Prefer renderer-provided context
|
||||
// (snippets + full contents), falling back to resolver when missing.
|
||||
const fileContents = new Map<string, FileChangeWithContent>();
|
||||
const memberName = request.memberName ?? '';
|
||||
|
||||
for (const d of request.decisions) {
|
||||
const snippets = d.snippets ?? [];
|
||||
|
||||
// If renderer provided full contents, use them directly.
|
||||
if (d.originalFullContent !== undefined || d.modifiedFullContent !== undefined) {
|
||||
fileContents.set(d.filePath, {
|
||||
filePath: d.filePath,
|
||||
relativePath: d.filePath.split('/').slice(-3).join('/'),
|
||||
snippets,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
isNewFile: d.isNewFile ?? snippets.some((s) => s.type === 'write-new'),
|
||||
originalFullContent: d.originalFullContent ?? null,
|
||||
modifiedFullContent: d.modifiedFullContent ?? null,
|
||||
// Source is informational only; "unavailable" avoids lying.
|
||||
contentSource: 'unavailable',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: resolve in main process (best-effort; task mode may not have memberName).
|
||||
const resolved = await getContentResolver().getFileContent(
|
||||
request.teamName,
|
||||
memberName,
|
||||
d.filePath,
|
||||
snippets
|
||||
);
|
||||
fileContents.set(d.filePath, resolved);
|
||||
}
|
||||
|
||||
return getApplier().applyReviewDecisions(request, fileContents);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetFileContent(
|
||||
|
|
@ -241,12 +278,14 @@ async function handleGetFileContent(
|
|||
async function handleSaveEditedFile(
|
||||
_event: IpcMainInvokeEvent,
|
||||
filePath: string,
|
||||
content: string
|
||||
content: string,
|
||||
projectPath?: string
|
||||
): Promise<IpcResult<{ success: boolean }>> {
|
||||
if (!filePath || typeof content !== 'string') {
|
||||
return { success: false, error: 'Invalid parameters' };
|
||||
}
|
||||
const pathCheck = validateFilePath(filePath, null);
|
||||
const resolvedProjectPath = projectPath && typeof projectPath === 'string' ? projectPath : null;
|
||||
const pathCheck = validateFilePath(filePath, resolvedProjectPath);
|
||||
if (!pathCheck.valid) {
|
||||
logger.error(`saveEditedFile blocked: ${String(pathCheck.error)} (path: ${String(filePath)})`);
|
||||
return { success: false, error: `Path validation failed: ${String(pathCheck.error)}` };
|
||||
|
|
@ -284,6 +323,7 @@ async function handleLoadDecisions(
|
|||
IpcResult<{
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
} | null>
|
||||
> {
|
||||
return wrapReviewHandler('loadDecisions', () => reviewDecisionStore.load(teamName, scopeKey));
|
||||
|
|
@ -294,10 +334,15 @@ async function handleSaveDecisions(
|
|||
teamName: string,
|
||||
scopeKey: string,
|
||||
hunkDecisions: Record<string, HunkDecision>,
|
||||
fileDecisions: Record<string, HunkDecision>
|
||||
fileDecisions: Record<string, HunkDecision>,
|
||||
hunkContextHashesByFile: Record<string, Record<number, string>> | null = null
|
||||
): Promise<IpcResult<void>> {
|
||||
return wrapReviewHandler('saveDecisions', () =>
|
||||
reviewDecisionStore.save(teamName, scopeKey, { hunkDecisions, fileDecisions })
|
||||
reviewDecisionStore.save(teamName, scopeKey, {
|
||||
hunkDecisions,
|
||||
fileDecisions,
|
||||
hunkContextHashesByFile: hunkContextHashesByFile ?? undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import {
|
||||
TEAM_ADD_MEMBER,
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
TEAM_ADD_TASK_RELATIONSHIP,
|
||||
TEAM_ALIVE_LIST,
|
||||
TEAM_CANCEL_PROVISIONING,
|
||||
TEAM_CREATE,
|
||||
|
|
@ -28,6 +30,7 @@ import {
|
|||
TEAM_PROVISIONING_PROGRESS,
|
||||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
|
|
@ -215,6 +218,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||||
ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification);
|
||||
ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification);
|
||||
ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship);
|
||||
ipcMain.handle(TEAM_REMOVE_TASK_RELATIONSHIP, handleRemoveTaskRelationship);
|
||||
logger.info('Team handlers registered');
|
||||
}
|
||||
|
||||
|
|
@ -261,6 +266,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||
ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION);
|
||||
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
|
||||
ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP);
|
||||
ipcMain.removeHandler(TEAM_REMOVE_TASK_RELATIONSHIP);
|
||||
}
|
||||
|
||||
function getTeamDataService(): TeamDataService {
|
||||
|
|
@ -1306,6 +1313,14 @@ async function handleCreateConfig(
|
|||
if (payload.color !== undefined && typeof payload.color !== 'string') {
|
||||
return { success: false, error: 'color must be a string' };
|
||||
}
|
||||
if (payload.cwd !== undefined) {
|
||||
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
|
||||
return { success: false, error: 'cwd must be a non-empty string if provided' };
|
||||
}
|
||||
if (!path.isAbsolute(payload.cwd.trim())) {
|
||||
return { success: false, error: 'cwd must be an absolute path' };
|
||||
}
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const members: TeamCreateConfigRequest['members'] = [];
|
||||
|
|
@ -1337,6 +1352,7 @@ async function handleCreateConfig(
|
|||
description: payload.description?.trim() || undefined,
|
||||
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
|
||||
members,
|
||||
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -1370,7 +1386,12 @@ async function handleGetLogsForTask(
|
|||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown,
|
||||
options?: { owner?: string; status?: string }
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
}
|
||||
): Promise<IpcResult<MemberLogSummary[]>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) {
|
||||
|
|
@ -1385,6 +1406,17 @@ async function handleGetLogsForTask(
|
|||
? {
|
||||
owner: typeof options.owner === 'string' ? options.owner : undefined,
|
||||
status: typeof options.status === 'string' ? options.status : undefined,
|
||||
since: typeof options.since === 'string' ? options.since : undefined,
|
||||
intervals: Array.isArray(options.intervals)
|
||||
? (options.intervals as unknown[]).filter(
|
||||
(i): i is { startedAt: string; completedAt?: string } =>
|
||||
Boolean(i) &&
|
||||
typeof i === 'object' &&
|
||||
typeof (i as Record<string, unknown>).startedAt === 'string' &&
|
||||
((i as Record<string, unknown>).completedAt === undefined ||
|
||||
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
return wrapTeamHandler('getLogsForTask', () =>
|
||||
|
|
@ -1714,22 +1746,31 @@ export function showTeamNativeNotification(opts: {
|
|||
body: string;
|
||||
}): void {
|
||||
const config = ConfigManager.getInstance().getConfig();
|
||||
if (!config.notifications.enabled) return;
|
||||
if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) return;
|
||||
if (!config.notifications.enabled) {
|
||||
logger.debug('[native-notification] skipped: notifications disabled');
|
||||
return;
|
||||
}
|
||||
if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) {
|
||||
logger.debug('[native-notification] skipped: snoozed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof Notification === 'undefined' ||
|
||||
typeof Notification.isSupported !== 'function' ||
|
||||
!Notification.isSupported()
|
||||
) {
|
||||
logger.warn('[native-notification] skipped: Notification not supported on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPath = getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: opts.title,
|
||||
subtitle: opts.subtitle,
|
||||
body: opts.body.slice(0, 300),
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
|
|
@ -1741,6 +1782,14 @@ export function showTeamNativeNotification(opts: {
|
|||
}
|
||||
});
|
||||
|
||||
notification.on('show', () => {
|
||||
logger.debug(`[native-notification] shown: "${opts.title}" — ${opts.subtitle ?? ''}`);
|
||||
});
|
||||
|
||||
notification.on('failed', (_, error) => {
|
||||
logger.warn(`[native-notification] failed: ${error}`);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
}
|
||||
|
||||
|
|
@ -1763,3 +1812,66 @@ async function handleAddTaskComment(
|
|||
getTeamDataService().addTaskComment(vTeam.value!, vTask.value!, text.trim())
|
||||
);
|
||||
}
|
||||
|
||||
const VALID_RELATIONSHIP_TYPES = ['blockedBy', 'blocks', 'related'] as const;
|
||||
type RelationshipType = (typeof VALID_RELATIONSHIP_TYPES)[number];
|
||||
|
||||
async function handleAddTaskRelationship(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown,
|
||||
targetId: unknown,
|
||||
type: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
const vTask = validateTaskId(taskId);
|
||||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||
const vTarget = validateTaskId(targetId);
|
||||
if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' };
|
||||
if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return wrapTeamHandler('addTaskRelationship', () =>
|
||||
getTeamDataService().addTaskRelationship(
|
||||
vTeam.value!,
|
||||
vTask.value!,
|
||||
vTarget.value!,
|
||||
type as RelationshipType
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRemoveTaskRelationship(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown,
|
||||
targetId: unknown,
|
||||
type: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
const vTask = validateTaskId(taskId);
|
||||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||
const vTarget = validateTaskId(targetId);
|
||||
if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' };
|
||||
if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return wrapTeamHandler('removeTaskRelationship', () =>
|
||||
getTeamDataService().removeTaskRelationship(
|
||||
vTeam.value!,
|
||||
vTask.value!,
|
||||
vTarget.value!,
|
||||
type as RelationshipType
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,10 +196,6 @@ export class ProjectScanner {
|
|||
// 1. Scan all projects using existing logic
|
||||
const projects = await this.scan();
|
||||
|
||||
if (projects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
|
||||
// Git identity resolution is bypassed to avoid blocking I/O on startup.
|
||||
// Each project becomes a single-worktree group.
|
||||
|
|
@ -223,6 +219,40 @@ export class ProjectScanner {
|
|||
totalSessions: project.sessions.length,
|
||||
}));
|
||||
|
||||
// 3. Merge custom project paths from config (persisted "Select Folder" picks)
|
||||
const { configManager } = await import('../infrastructure/ConfigManager');
|
||||
const customPaths = configManager.getCustomProjectPaths();
|
||||
const existingPaths = new Set(groups.flatMap((g) => g.worktrees.map((w) => w.path)));
|
||||
|
||||
for (const customPath of customPaths) {
|
||||
if (existingPaths.has(customPath)) {
|
||||
continue; // Already discovered by scanner — skip
|
||||
}
|
||||
|
||||
const encodedId = customPath.replace(/[/\\]/g, '-');
|
||||
const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath;
|
||||
const now = Date.now();
|
||||
|
||||
groups.push({
|
||||
id: encodedId,
|
||||
identity: null,
|
||||
worktrees: [
|
||||
{
|
||||
id: encodedId,
|
||||
path: customPath,
|
||||
name: folderName,
|
||||
isMainWorktree: true,
|
||||
source: 'unknown' as const,
|
||||
sessions: [],
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
name: folderName,
|
||||
mostRecentSession: undefined,
|
||||
totalSessions: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by most recent activity (same order as the full git-aware version)
|
||||
groups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
|
||||
|
||||
|
|
@ -309,7 +339,8 @@ export class ProjectScanner {
|
|||
|
||||
// Group sessions by cwd
|
||||
const cwdGroups = new Map<string, SessionInfo[]>();
|
||||
const baseName = extractProjectName(encodedName);
|
||||
const firstCwd = sessionInfos.find((s) => s.cwd)?.cwd ?? undefined;
|
||||
const baseName = extractProjectName(encodedName, firstCwd);
|
||||
const decodedFallback = baseName; // Used when cwd is null
|
||||
|
||||
for (const info of sessionInfos) {
|
||||
|
|
@ -341,11 +372,15 @@ export class ProjectScanner {
|
|||
sessionPaths,
|
||||
});
|
||||
|
||||
// Derive name from resolved path — more reliable than decodePath for
|
||||
// paths containing dashes (e.g. "test-project" encodes lossily).
|
||||
const resolvedName = path.basename(actualPath) || baseName;
|
||||
|
||||
return [
|
||||
{
|
||||
id: encodedName,
|
||||
path: actualPath,
|
||||
name: baseName,
|
||||
name: resolvedName,
|
||||
sessions: allSessionIds,
|
||||
createdAt: Math.floor(createdAt),
|
||||
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
|
||||
|
|
@ -362,6 +397,8 @@ export class ProjectScanner {
|
|||
(shortest, cwd) => (cwd.length <= shortest.length ? cwd : shortest),
|
||||
cwdKeys[0] ?? ''
|
||||
);
|
||||
// Derive root name from actual cwd path (more reliable than decodePath)
|
||||
const rootName = path.basename(rootCwd) || baseName;
|
||||
|
||||
for (const [cwdKey, sessions] of cwdGroups) {
|
||||
const isDecodedFallback = cwdKey.startsWith('__decoded__');
|
||||
|
|
@ -387,14 +424,14 @@ export class ProjectScanner {
|
|||
}
|
||||
}
|
||||
|
||||
// Build display name
|
||||
// Build display name from actual cwd paths
|
||||
let displayName: string;
|
||||
if (!actualCwd || actualCwd === rootCwd) {
|
||||
displayName = baseName;
|
||||
displayName = rootName;
|
||||
} else {
|
||||
// Use last segment of cwd for disambiguation
|
||||
const lastSegment = path.basename(actualCwd);
|
||||
displayName = `${baseName} (${lastSegment})`;
|
||||
displayName = `${rootName} (${lastSegment})`;
|
||||
}
|
||||
|
||||
projects.push({
|
||||
|
|
|
|||
|
|
@ -22,11 +22,8 @@ const log = createLogger('EditorFileWatcher');
|
|||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
/** Directories to ignore (regex for chokidar's `ignored` option) */
|
||||
const IGNORED_PATTERN =
|
||||
/(node_modules|\.git|dist|__pycache__|\.cache|\.next|\.venv|\.tox|vendor|\.DS_Store)/;
|
||||
|
||||
const MAX_DEPTH = 20;
|
||||
const STARTUP_IGNORE_CHANGE_MS = 3000;
|
||||
const MAX_EMITTED_EVENTS_PER_FLUSH = 300;
|
||||
|
||||
// =============================================================================
|
||||
// Service
|
||||
|
|
@ -34,32 +31,80 @@ const MAX_DEPTH = 20;
|
|||
|
||||
export class EditorFileWatcher {
|
||||
private watcher: FSWatcher | null = null;
|
||||
private dirWatcher: FSWatcher | null = null;
|
||||
private projectRoot: string | null = null;
|
||||
private pendingEvents = new Map<string, EditorFileChangeEvent['type']>();
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private onChangeCallback: ((event: EditorFileChangeEvent) => void) | null = null;
|
||||
// Higher debounce = fewer IPC events during large bursts (checkout/build/format).
|
||||
private readonly DEBOUNCE_MS = 350;
|
||||
private ignoreChangeUntilMs = 0;
|
||||
private watchedFilesKey = '';
|
||||
private watchedDirsKey = '';
|
||||
|
||||
/**
|
||||
* Start watching a project directory.
|
||||
* Idempotent: stops any existing watcher first.
|
||||
* Initialize watcher context for a project root.
|
||||
*
|
||||
* Performance: does NOT watch the entire project directory.
|
||||
* Use setWatchedFiles() to watch only open files (tabs).
|
||||
*/
|
||||
start(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void): void {
|
||||
this.stop();
|
||||
this.projectRoot = projectRoot;
|
||||
this.ignoreChangeUntilMs = Date.now() + STARTUP_IGNORE_CHANGE_MS;
|
||||
this.watchedFilesKey = '';
|
||||
this.watchedDirsKey = '';
|
||||
|
||||
log.info('Starting file watcher for:', projectRoot);
|
||||
log.info('Starting file watcher (open files only) for:', projectRoot);
|
||||
this.onChangeCallback = onChange;
|
||||
}
|
||||
|
||||
this.watcher = watch(projectRoot, {
|
||||
ignored: IGNORED_PATTERN,
|
||||
/**
|
||||
* Update list of watched file paths (open tabs).
|
||||
* Rebuilds chokidar watcher when the set changes.
|
||||
*/
|
||||
setWatchedFiles(filePaths: string[]): void {
|
||||
if (!this.projectRoot) {
|
||||
return; // Watcher not initialized yet — will sync when start() is called
|
||||
}
|
||||
|
||||
const normalized = filePaths
|
||||
.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
||||
.filter((p) => isPathWithinRoot(p, this.projectRoot!));
|
||||
|
||||
normalized.sort((a, b) => a.localeCompare(b));
|
||||
const key = normalized.join('\n');
|
||||
if (key === this.watchedFilesKey) return;
|
||||
this.watchedFilesKey = key;
|
||||
|
||||
// Close existing watcher first (if any)
|
||||
if (this.watcher) {
|
||||
void this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a new watcher for the given file set.
|
||||
// disableGlobbing prevents chokidar from treating file names as patterns.
|
||||
this.watcher = watch(normalized, {
|
||||
ignoreInitial: true,
|
||||
ignorePermissionErrors: true,
|
||||
followSymlinks: false,
|
||||
depth: MAX_DEPTH,
|
||||
});
|
||||
|
||||
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
|
||||
// SEC-2: validate path is within project root before sending to renderer
|
||||
if (!isPathWithinRoot(filePath, projectRoot)) {
|
||||
if (type === 'change' && Date.now() < this.ignoreChangeUntilMs) {
|
||||
return;
|
||||
}
|
||||
if (!isPathWithinRoot(filePath, this.projectRoot!)) {
|
||||
log.warn('Watcher event outside project root, ignoring:', filePath);
|
||||
return;
|
||||
}
|
||||
onChange({ type, path: filePath });
|
||||
this.pendingEvents.set(filePath, type);
|
||||
this.scheduleFlush();
|
||||
};
|
||||
|
||||
this.watcher.on('change', (p) => emitSafe('change', p));
|
||||
|
|
@ -71,18 +116,122 @@ export class EditorFileWatcher {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update list of watched directory paths (shallow: depth=0).
|
||||
* Watches only immediate children changes (create/delete/rename) in those folders.
|
||||
*/
|
||||
setWatchedDirs(dirPaths: string[]): void {
|
||||
if (!this.projectRoot) {
|
||||
return; // Watcher not initialized yet — will sync when start() is called
|
||||
}
|
||||
|
||||
const normalized = dirPaths
|
||||
.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
||||
.filter((p) => isPathWithinRoot(p, this.projectRoot!));
|
||||
|
||||
normalized.sort((a, b) => a.localeCompare(b));
|
||||
const key = normalized.join('\n');
|
||||
if (key === this.watchedDirsKey) return;
|
||||
this.watchedDirsKey = key;
|
||||
|
||||
if (this.dirWatcher) {
|
||||
void this.dirWatcher.close();
|
||||
this.dirWatcher = null;
|
||||
}
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dirWatcher = watch(normalized, {
|
||||
ignoreInitial: true,
|
||||
ignorePermissionErrors: true,
|
||||
followSymlinks: false,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
|
||||
if (!isPathWithinRoot(filePath, this.projectRoot!)) {
|
||||
log.warn('Watcher event outside project root, ignoring:', filePath);
|
||||
return;
|
||||
}
|
||||
this.pendingEvents.set(filePath, type);
|
||||
this.scheduleFlush();
|
||||
};
|
||||
|
||||
// For directories, we only care about structural changes.
|
||||
this.dirWatcher.on('add', (p) => emitSafe('create', p));
|
||||
this.dirWatcher.on('unlink', (p) => emitSafe('delete', p));
|
||||
this.dirWatcher.on('addDir', (p) => emitSafe('create', p));
|
||||
this.dirWatcher.on('unlinkDir', (p) => emitSafe('delete', p));
|
||||
|
||||
this.dirWatcher.on('error', (error) => {
|
||||
log.error('Dir watcher error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching. Safe to call multiple times.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
this.pendingEvents.clear();
|
||||
this.onChangeCallback = null;
|
||||
this.ignoreChangeUntilMs = 0;
|
||||
this.watchedFilesKey = '';
|
||||
this.watchedDirsKey = '';
|
||||
if (this.watcher) {
|
||||
log.info('Stopping file watcher');
|
||||
void this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
if (this.dirWatcher) {
|
||||
log.info('Stopping directory watcher');
|
||||
void this.dirWatcher.close();
|
||||
this.dirWatcher = null;
|
||||
}
|
||||
this.projectRoot = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending events — debounced to aggregate rapid FS changes
|
||||
* (e.g. git checkout, bulk format). Fires once after 150ms of quiet.
|
||||
*/
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushTimer) return;
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
const events = new Map(this.pendingEvents);
|
||||
this.pendingEvents.clear();
|
||||
if (!this.onChangeCallback) return;
|
||||
// Cap emitted events per flush to protect renderer from floods.
|
||||
// Prefer create/delete events over change events.
|
||||
let emitted = 0;
|
||||
|
||||
if (events.size > MAX_EMITTED_EVENTS_PER_FLUSH) {
|
||||
log.warn(
|
||||
`Watcher burst: ${events.size} events pending, capping to ${MAX_EMITTED_EVENTS_PER_FLUSH}`
|
||||
);
|
||||
}
|
||||
|
||||
const emit = (type: EditorFileChangeEvent['type']): void => {
|
||||
for (const [filePath, t] of events) {
|
||||
if (t !== type) continue;
|
||||
this.onChangeCallback?.({ type: t, path: filePath });
|
||||
emitted++;
|
||||
if (emitted >= MAX_EMITTED_EVENTS_PER_FLUSH) return;
|
||||
}
|
||||
};
|
||||
|
||||
emit('delete');
|
||||
if (emitted < MAX_EMITTED_EVENTS_PER_FLUSH) emit('create');
|
||||
if (emitted < MAX_EMITTED_EVENTS_PER_FLUSH) emit('change');
|
||||
}, this.DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the watcher is currently active.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export class FileSearchService {
|
|||
}
|
||||
|
||||
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const subdirs: string[] = [];
|
||||
|
||||
for (const entry of sorted) {
|
||||
if (signal?.aborted || files.length >= MAX_FILES) break;
|
||||
|
|
@ -158,7 +159,7 @@ export class FileSearchService {
|
|||
|
||||
if (entry.isDirectory()) {
|
||||
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
||||
await this.collectFilePaths(projectRoot, fullPath, files, signal);
|
||||
subdirs.push(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
if (IGNORED_FILES.has(entry.name)) continue;
|
||||
const relativePath = fullPath.startsWith(projectRoot)
|
||||
|
|
@ -167,6 +168,14 @@ export class FileSearchService {
|
|||
files.push({ path: fullPath, name: entry.name, relativePath });
|
||||
}
|
||||
}
|
||||
|
||||
// Parallel subdirectory traversal (batched)
|
||||
const DIR_CONCURRENCY = 10;
|
||||
for (let i = 0; i < subdirs.length; i += DIR_CONCURRENCY) {
|
||||
if (signal?.aborted || files.length >= MAX_FILES) break;
|
||||
const batch = subdirs.slice(i, i + DIR_CONCURRENCY);
|
||||
await Promise.all(batch.map((dir) => this.collectFilePaths(projectRoot, dir, files, signal)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -194,8 +203,11 @@ export class FileSearchService {
|
|||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const candidates: string[] = [];
|
||||
const subdirs: string[] = [];
|
||||
|
||||
for (const entry of sorted) {
|
||||
if (signal?.aborted || files.length >= MAX_FILES) break;
|
||||
if (signal?.aborted) break;
|
||||
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
|
|
@ -207,32 +219,48 @@ export class FileSearchService {
|
|||
|
||||
if (entry.isDirectory()) {
|
||||
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
||||
await this.collectFiles(projectRoot, fullPath, files, signal);
|
||||
subdirs.push(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
if (IGNORED_FILES.has(entry.name)) continue;
|
||||
|
||||
// Skip files > 1MB
|
||||
try {
|
||||
const stat = await fs.stat(fullPath);
|
||||
if (stat.size > MAX_FILE_SIZE) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip binary files (quick check via first 512 bytes)
|
||||
try {
|
||||
if (await isBinaryFile(fullPath)) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(fullPath);
|
||||
candidates.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Parallel stat + binary check (batched by 20 for I/O concurrency)
|
||||
const CHECK_CONCURRENCY = 20;
|
||||
for (let i = 0; i < candidates.length; i += CHECK_CONCURRENCY) {
|
||||
if (signal?.aborted || files.length >= MAX_FILES) break;
|
||||
const batch = candidates.slice(i, i + CHECK_CONCURRENCY);
|
||||
const results = await Promise.all(
|
||||
batch.map(async (fp) => {
|
||||
try {
|
||||
const stat = await fs.stat(fp);
|
||||
if (stat.size > MAX_FILE_SIZE) return null;
|
||||
if (await isBinaryFile(fp)) return null;
|
||||
return fp;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
for (const fp of results) {
|
||||
if (fp && files.length < MAX_FILES) files.push(fp);
|
||||
}
|
||||
}
|
||||
|
||||
// Parallel subdirectory traversal (batched)
|
||||
const DIR_CONCURRENCY = 10;
|
||||
for (let i = 0; i < subdirs.length; i += DIR_CONCURRENCY) {
|
||||
if (signal?.aborted || files.length >= MAX_FILES) break;
|
||||
const batch = subdirs.slice(i, i + DIR_CONCURRENCY);
|
||||
await Promise.all(batch.map((dir) => this.collectFiles(projectRoot, dir, files, signal)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a single file for literal string matches.
|
||||
* Uses indexOf on the full content instead of split() — avoids 50k+ string allocations
|
||||
* for a 1MB file. Running line counter keeps O(n) total regardless of match count.
|
||||
*/
|
||||
private async searchFile(
|
||||
filePath: string,
|
||||
|
|
@ -242,29 +270,38 @@ export class FileSearchService {
|
|||
): Promise<SearchMatch[]> {
|
||||
if (signal?.aborted) return [];
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const content = caseSensitive ? raw : raw.toLowerCase();
|
||||
const matches: SearchMatch[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let searchFrom = 0;
|
||||
let line = 1;
|
||||
let lastCountedPos = 0;
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) break;
|
||||
const idx = content.indexOf(query, searchFrom);
|
||||
if (idx === -1) break;
|
||||
|
||||
const line = lines[i];
|
||||
const searchLine = caseSensitive ? line : line.toLowerCase();
|
||||
let startIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const idx = searchLine.indexOf(query, startIndex);
|
||||
if (idx === -1) break;
|
||||
|
||||
matches.push({
|
||||
line: i + 1,
|
||||
column: idx,
|
||||
lineContent: line.trim(),
|
||||
});
|
||||
|
||||
startIndex = idx + query.length;
|
||||
// Running line counter — count \n from lastCountedPos to idx
|
||||
for (let i = lastCountedPos; i < idx; i++) {
|
||||
if (content.charCodeAt(i) === 10) line++;
|
||||
}
|
||||
lastCountedPos = idx;
|
||||
|
||||
// Extract line content from raw text
|
||||
let lineStart = raw.lastIndexOf('\n', idx);
|
||||
lineStart = lineStart === -1 ? 0 : lineStart + 1;
|
||||
let lineEnd = raw.indexOf('\n', idx);
|
||||
if (lineEnd === -1) lineEnd = raw.length;
|
||||
|
||||
matches.push({
|
||||
line,
|
||||
column: idx - lineStart,
|
||||
lineContent: raw.slice(lineStart, lineEnd).trim(),
|
||||
});
|
||||
|
||||
searchFrom = idx + query.length;
|
||||
}
|
||||
|
||||
return matches;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ const log = createLogger('GitStatusService');
|
|||
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_MS = 5_000;
|
||||
const CHANGE_INVALIDATION_DEBOUNCE_MS = 500;
|
||||
const GIT_STATUS_ARGS: string[] = ['--untracked-files=no'];
|
||||
|
||||
// =============================================================================
|
||||
// Service
|
||||
|
|
@ -28,10 +30,13 @@ const CACHE_TTL_MS = 5_000;
|
|||
export class GitStatusService {
|
||||
private git: SimpleGit | null = null;
|
||||
private projectRoot: string | null = null;
|
||||
/** Set to true when we confirm the project is not a git repo — skip all future git calls. */
|
||||
private notAGitRepo = false;
|
||||
|
||||
// Cache
|
||||
private cachedResult: GitStatusResult | null = null;
|
||||
private cacheTimestamp = 0;
|
||||
private changeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Initialize service for a project root.
|
||||
|
|
@ -39,6 +44,7 @@ export class GitStatusService {
|
|||
*/
|
||||
init(projectRoot: string): void {
|
||||
this.projectRoot = projectRoot;
|
||||
this.notAGitRepo = false;
|
||||
this.git = simpleGit({
|
||||
baseDir: projectRoot,
|
||||
timeout: { block: GIT_TIMEOUT_MS },
|
||||
|
|
@ -50,64 +56,91 @@ export class GitStatusService {
|
|||
* Reset service state.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.clearDebounceTimer();
|
||||
this.git = null;
|
||||
this.projectRoot = null;
|
||||
this.notAGitRepo = false;
|
||||
this.cachedResult = null;
|
||||
this.cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached status (e.g. on file watcher event).
|
||||
* Immediate cache invalidation for structural changes (create/delete).
|
||||
* Also cancels any pending debounced invalidation.
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.clearDebounceTimer();
|
||||
this.cachedResult = null;
|
||||
this.cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced cache invalidation for content changes.
|
||||
* Coalesces rapid file saves (typing, format-on-save, build output)
|
||||
* into a single invalidation after the burst settles.
|
||||
*/
|
||||
invalidateCacheDebounced(): void {
|
||||
this.clearDebounceTimer();
|
||||
this.changeDebounceTimer = setTimeout(() => {
|
||||
this.changeDebounceTimer = null;
|
||||
this.cachedResult = null;
|
||||
this.cacheTimestamp = 0;
|
||||
}, CHANGE_INVALIDATION_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git status for the current project.
|
||||
* Returns cached result if within TTL.
|
||||
*/
|
||||
async getStatus(): Promise<GitStatusResult> {
|
||||
if (!this.git || !this.projectRoot) {
|
||||
return { files: [], isGitRepo: false, branch: null };
|
||||
const notGitResult: GitStatusResult = { files: [], isGitRepo: false, branch: null };
|
||||
|
||||
if (!this.git || !this.projectRoot || this.notAGitRepo) {
|
||||
return notGitResult;
|
||||
}
|
||||
|
||||
// Flush pending debounced invalidation — when data is actually requested,
|
||||
// stale cache must not be served even if the debounce hasn't settled yet.
|
||||
if (this.changeDebounceTimer) {
|
||||
this.invalidateCache();
|
||||
}
|
||||
|
||||
// Return cached if fresh
|
||||
if (this.cachedResult && Date.now() - this.cacheTimestamp < CACHE_TTL_MS) {
|
||||
log.info('[perf] gitStatus: cache hit');
|
||||
return this.cachedResult;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if it's a git repo first
|
||||
const isRepo = await this.isGitRepo();
|
||||
if (!isRepo) {
|
||||
const result: GitStatusResult = { files: [], isGitRepo: false, branch: null };
|
||||
this.setCacheResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const statusResult = await this.git.status();
|
||||
const t0 = performance.now();
|
||||
// `--untracked-files=no` is a major performance win for large repos with many untracked files.
|
||||
// Most editors treat untracked as a separate concern; showing them can overwhelm the UI.
|
||||
const statusResult = await this.git.status(GIT_STATUS_ARGS);
|
||||
const gitMs = performance.now() - t0;
|
||||
const files = mapStatusResult(statusResult);
|
||||
const branch = statusResult.current ?? null;
|
||||
log.info(
|
||||
`[perf] gitStatus: git=${gitMs.toFixed(1)}ms, files=${files.length}, branch=${branch ?? 'detached'}, untracked=off`
|
||||
);
|
||||
|
||||
const result: GitStatusResult = { files, isGitRepo: true, branch };
|
||||
this.setCacheResult(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.error('Failed to get git status:', error);
|
||||
// Graceful degradation: return empty non-repo result
|
||||
return { files: [], isGitRepo: false, branch: null };
|
||||
}
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
private async isGitRepo(): Promise<boolean> {
|
||||
if (!this.git) return false;
|
||||
try {
|
||||
await this.git.revparse(['--is-inside-work-tree']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
if (message.includes('not a git repository')) {
|
||||
// Expected condition — project is not a git repo. Stop all future git calls
|
||||
// until init() is called again with a different project.
|
||||
log.info('Project is not a git repository, disabling git status');
|
||||
this.notAGitRepo = true;
|
||||
return notGitResult;
|
||||
}
|
||||
|
||||
log.error('Failed to get git status:', error);
|
||||
// Transient error — cache negative result to avoid hammering git, but allow retry after TTL
|
||||
this.setCacheResult(notGitResult);
|
||||
return notGitResult;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +148,13 @@ export class GitStatusService {
|
|||
this.cachedResult = result;
|
||||
this.cacheTimestamp = Date.now();
|
||||
}
|
||||
|
||||
private clearDebounceTimer(): void {
|
||||
if (this.changeDebounceTimer) {
|
||||
clearTimeout(this.changeDebounceTimer);
|
||||
this.changeDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { isBinaryFile } from 'isbinaryfile';
|
|||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
BinaryPreviewResult,
|
||||
CreateDirResponse,
|
||||
CreateFileResponse,
|
||||
DeleteFileResponse,
|
||||
|
|
@ -43,6 +44,32 @@ const MAX_WRITE_SIZE = 2 * 1024 * 1024; // 2 MB
|
|||
const MAX_DIR_ENTRIES = 500;
|
||||
const PREVIEW_LINE_COUNT = 100;
|
||||
|
||||
/**
|
||||
* Extract the first N lines from text using indexOf — O(1) allocations vs split().
|
||||
* For a 5MB file with 100k lines, avoids creating 100k string objects.
|
||||
*/
|
||||
function sliceFirstNLines(text: string, n: number): string {
|
||||
let pos = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const next = text.indexOf('\n', pos);
|
||||
if (next === -1) return text;
|
||||
pos = next + 1;
|
||||
}
|
||||
return text.slice(0, pos > 0 ? pos - 1 : 0);
|
||||
}
|
||||
|
||||
const PREVIEW_MIME_MAP: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
const MAX_PREVIEW_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
const IGNORED_DIRS = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
|
|
@ -77,6 +104,7 @@ export class ProjectFileService {
|
|||
dirPath: string,
|
||||
maxEntries: number = MAX_DIR_ENTRIES
|
||||
): Promise<ReadDirResult> {
|
||||
const t0 = performance.now();
|
||||
const normalizedDir = path.resolve(dirPath);
|
||||
|
||||
// Containment check (allow sensitive files to be listed with flag)
|
||||
|
|
@ -90,51 +118,58 @@ export class ProjectFileService {
|
|||
}
|
||||
|
||||
const dirents = await fs.readdir(normalizedDir, { withFileTypes: true });
|
||||
const entries: FileTreeEntry[] = [];
|
||||
let truncated = false;
|
||||
|
||||
// Phase 1: classify entries without I/O (instant)
|
||||
const pendingEntries: {
|
||||
dirent: { name: string };
|
||||
entryPath: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
}[] = [];
|
||||
|
||||
for (const dirent of dirents) {
|
||||
// Ignore well-known noise
|
||||
if (dirent.isDirectory() && IGNORED_DIRS.has(dirent.name)) continue;
|
||||
if (dirent.isFile() && IGNORED_FILES.has(dirent.name)) continue;
|
||||
|
||||
const entryPath = path.join(normalizedDir, dirent.name);
|
||||
if (!isPathWithinRoot(entryPath, projectRoot)) continue;
|
||||
if (isGitInternalPath(entryPath)) continue;
|
||||
|
||||
// Symlink handling: resolve and re-check containment
|
||||
if (dirent.isSymbolicLink()) {
|
||||
try {
|
||||
const realPath = await fs.realpath(entryPath);
|
||||
if (!isPathWithinAllowedDirectories(realPath, projectRoot)) {
|
||||
continue; // Silently skip symlinks that escape project root (SEC-2)
|
||||
}
|
||||
const realStat = await fs.stat(realPath);
|
||||
const entry = this.buildEntry(
|
||||
dirent.name,
|
||||
entryPath,
|
||||
realStat.isDirectory() ? 'directory' : 'file',
|
||||
realStat.isFile() ? realStat.size : undefined
|
||||
);
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Broken symlink — skip silently
|
||||
continue;
|
||||
}
|
||||
pendingEntries.push({ dirent, entryPath, type: 'symlink' });
|
||||
} else if (dirent.isDirectory()) {
|
||||
entries.push(this.buildEntry(dirent.name, entryPath, 'directory'));
|
||||
pendingEntries.push({ dirent, entryPath, type: 'directory' });
|
||||
} else if (dirent.isFile()) {
|
||||
try {
|
||||
const fileStat = await fs.stat(entryPath);
|
||||
entries.push(this.buildEntry(dirent.name, entryPath, 'file', fileStat.size));
|
||||
} catch {
|
||||
// Can't stat — include without size
|
||||
entries.push(this.buildEntry(dirent.name, entryPath, 'file'));
|
||||
}
|
||||
pendingEntries.push({ dirent, entryPath, type: 'file' });
|
||||
}
|
||||
// Skip other types (block devices, sockets, etc.)
|
||||
|
||||
if (entries.length >= maxEntries) {
|
||||
truncated = true;
|
||||
break;
|
||||
if (pendingEntries.length >= maxEntries) break;
|
||||
}
|
||||
|
||||
// Phase 2: resolve entries in parallel (I/O-bound)
|
||||
const STAT_CONCURRENCY = 50;
|
||||
const entries: FileTreeEntry[] = [];
|
||||
|
||||
for (let i = 0; i < pendingEntries.length; i += STAT_CONCURRENCY) {
|
||||
const batch = pendingEntries.slice(i, i + STAT_CONCURRENCY);
|
||||
const resolved = await Promise.all(
|
||||
batch.map(async ({ dirent, entryPath, type }) => {
|
||||
if (type === 'directory') {
|
||||
return this.buildEntry(dirent.name, entryPath, 'directory');
|
||||
}
|
||||
if (type === 'symlink') {
|
||||
return this.resolveSymlinkEntry(dirent.name, entryPath, projectRoot);
|
||||
}
|
||||
// file — stat for size
|
||||
try {
|
||||
const fileStat = await fs.stat(entryPath);
|
||||
return this.buildEntry(dirent.name, entryPath, 'file', fileStat.size);
|
||||
} catch {
|
||||
return this.buildEntry(dirent.name, entryPath, 'file');
|
||||
}
|
||||
})
|
||||
);
|
||||
for (const entry of resolved) {
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +179,14 @@ export class ProjectFileService {
|
|||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return { entries, truncated };
|
||||
const totalMs = performance.now() - t0;
|
||||
if (totalMs > 50) {
|
||||
log.info(
|
||||
`[perf] readDir: ${totalMs.toFixed(1)}ms, entries=${entries.length}, dirents=${dirents.length}, dir=${path.basename(normalizedDir)}`
|
||||
);
|
||||
}
|
||||
|
||||
return { entries, truncated: pendingEntries.length >= maxEntries };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -209,7 +251,7 @@ export class ProjectFileService {
|
|||
|
||||
// 8. Tiered response
|
||||
const isPreview = stats.size > MAX_FILE_SIZE_FULL;
|
||||
const content = isPreview ? raw.split('\n').slice(0, PREVIEW_LINE_COUNT).join('\n') : raw;
|
||||
const content = isPreview ? sliceFirstNLines(raw, PREVIEW_LINE_COUNT) : raw;
|
||||
|
||||
return {
|
||||
content,
|
||||
|
|
@ -505,7 +547,7 @@ export class ProjectFileService {
|
|||
const newPath = path.join(normalizedDest, path.basename(normalizedSrc));
|
||||
|
||||
// 8. Prevent parent → child move (moving dir into itself)
|
||||
if (normalizedDest.startsWith(normalizedSrc + '/') || normalizedDest === normalizedSrc) {
|
||||
if (normalizedDest.startsWith(normalizedSrc + path.sep) || normalizedDest === normalizedSrc) {
|
||||
throw new Error('Cannot move a directory into itself');
|
||||
}
|
||||
|
||||
|
|
@ -529,8 +571,8 @@ export class ProjectFileService {
|
|||
await fs.rename(normalizedSrc, newPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||
const stat = await fs.lstat(normalizedSrc);
|
||||
if (stat.isDirectory()) {
|
||||
// Reuse srcStat from step 5 — no need for another fs.lstat
|
||||
if (isDirectory) {
|
||||
await fs.cp(normalizedSrc, newPath, { recursive: true });
|
||||
} else {
|
||||
await fs.copyFile(normalizedSrc, newPath);
|
||||
|
|
@ -545,10 +587,152 @@ export class ProjectFileService {
|
|||
return { newPath, isDirectory };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a file or directory in place (same parent directory).
|
||||
*/
|
||||
async renameFile(
|
||||
projectRoot: string,
|
||||
sourcePath: string,
|
||||
newName: string
|
||||
): Promise<MoveFileResponse> {
|
||||
// 1. Validate new name
|
||||
const nameValidation = validateFileName(newName);
|
||||
if (!nameValidation.valid) {
|
||||
throw new Error(nameValidation.error);
|
||||
}
|
||||
|
||||
// 2. Validate source path
|
||||
const srcValidation = validateFilePath(sourcePath, projectRoot);
|
||||
if (!srcValidation.valid) {
|
||||
throw new Error(srcValidation.error);
|
||||
}
|
||||
const normalizedSrc = srcValidation.normalizedPath!;
|
||||
|
||||
// 3. Project containment
|
||||
if (!isPathWithinRoot(normalizedSrc, projectRoot)) {
|
||||
throw new Error('Source path is outside project root');
|
||||
}
|
||||
|
||||
// 4. Block .git/ paths
|
||||
if (isGitInternalPath(normalizedSrc)) {
|
||||
throw new Error('Cannot rename files in .git/ directory');
|
||||
}
|
||||
|
||||
// 5. Verify source exists
|
||||
const srcStat = await fs.lstat(normalizedSrc);
|
||||
const isDirectory = srcStat.isDirectory();
|
||||
|
||||
// 6. Build new path (same parent, new name)
|
||||
const parentDir = path.dirname(normalizedSrc);
|
||||
const newPath = path.join(parentDir, newName);
|
||||
|
||||
// 7. Check new path doesn't already exist
|
||||
try {
|
||||
await fs.access(newPath);
|
||||
throw new Error('A file or folder with that name already exists');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Block sensitive destination
|
||||
if (matchesSensitivePattern(newPath)) {
|
||||
throw new Error('Cannot rename to a sensitive file name');
|
||||
}
|
||||
|
||||
// 9. Perform rename
|
||||
await fs.rename(normalizedSrc, newPath);
|
||||
|
||||
log.info('File renamed:', normalizedSrc, '→', newPath);
|
||||
return { newPath, isDirectory };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a binary file as base64 for inline preview (images, etc.).
|
||||
*
|
||||
* Security:
|
||||
* - validateFilePath for traversal + sensitive check (SEC-1)
|
||||
* - Device path blocking (SEC-4)
|
||||
* - lstat + isFile check (SEC-4)
|
||||
* - Size limit (10MB)
|
||||
* - Post-read TOCTOU realpath verify (SEC-3)
|
||||
*/
|
||||
async readBinaryPreview(projectRoot: string, filePath: string): Promise<BinaryPreviewResult> {
|
||||
// 1. Path validation
|
||||
const validation = validateFilePath(filePath, projectRoot);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
|
||||
const normalizedPath = validation.normalizedPath!;
|
||||
|
||||
// 2. Device path block
|
||||
if (isDevicePath(normalizedPath)) {
|
||||
throw new Error('Cannot read device files');
|
||||
}
|
||||
|
||||
// 3. File type check
|
||||
const stats = await fs.lstat(normalizedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Not a regular file');
|
||||
}
|
||||
|
||||
// 4. Size check
|
||||
if (stats.size > MAX_PREVIEW_SIZE) {
|
||||
throw new Error(
|
||||
`File too large for preview (${(stats.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.`
|
||||
);
|
||||
}
|
||||
|
||||
// 5. MIME type from extension
|
||||
const ext = path.extname(normalizedPath).toLowerCase();
|
||||
const mimeType = PREVIEW_MIME_MAP[ext];
|
||||
if (!mimeType) {
|
||||
throw new Error(`Unsupported preview format: ${ext}`);
|
||||
}
|
||||
|
||||
// 6. Read file as Buffer → base64
|
||||
const buffer = await fs.readFile(normalizedPath);
|
||||
|
||||
// 7. Post-read TOCTOU verify
|
||||
const realPath = await fs.realpath(normalizedPath);
|
||||
const postValidation = validateFilePath(realPath, projectRoot);
|
||||
if (!postValidation.valid) {
|
||||
throw new Error('Path changed during read (TOCTOU)');
|
||||
}
|
||||
|
||||
return {
|
||||
base64: buffer.toString('base64'),
|
||||
mimeType,
|
||||
size: stats.size,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async resolveSymlinkEntry(
|
||||
name: string,
|
||||
entryPath: string,
|
||||
projectRoot: string
|
||||
): Promise<FileTreeEntry | null> {
|
||||
try {
|
||||
const realPath = await fs.realpath(entryPath);
|
||||
if (!isPathWithinAllowedDirectories(realPath, projectRoot)) return null;
|
||||
const realStat = await fs.stat(realPath);
|
||||
return this.buildEntry(
|
||||
name,
|
||||
entryPath,
|
||||
realStat.isDirectory() ? 'directory' : 'file',
|
||||
realStat.isFile() ? realStat.size : undefined
|
||||
);
|
||||
} catch {
|
||||
return null; // broken symlink
|
||||
}
|
||||
}
|
||||
|
||||
private buildEntry(
|
||||
name: string,
|
||||
entryPath: string,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,10 @@ const MAX_REDIRECTS = 5;
|
|||
const HTTP_CONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Overall timeout for getStatus() to prevent UI hanging indefinitely (ms) */
|
||||
const GET_STATUS_TIMEOUT_MS = 25_000;
|
||||
const GET_STATUS_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Overall timeout for the auth status check (covers both attempts + retry delay) (ms) */
|
||||
const AUTH_TOTAL_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Max retries for EBUSY (antivirus scanning the new binary) */
|
||||
const EBUSY_MAX_RETRIES = 3;
|
||||
|
|
@ -66,6 +69,12 @@ const EBUSY_MAX_RETRIES = 3;
|
|||
/** Delay between EBUSY retries (multiplied by attempt number) */
|
||||
const EBUSY_RETRY_DELAY_MS = 2000;
|
||||
|
||||
/** Max retries for auth status check (covers stale locks after Ctrl+C) */
|
||||
const AUTH_STATUS_MAX_RETRIES = 2;
|
||||
|
||||
/** Delay before retrying auth status check (ms) — gives previous process time to clean up */
|
||||
const AUTH_STATUS_RETRY_DELAY_MS = 1500;
|
||||
|
||||
/**
|
||||
* Build env for child processes with correct HOME.
|
||||
* On Windows with non-ASCII usernames, process.env may have a broken HOME/USERPROFILE.
|
||||
|
|
@ -263,6 +272,8 @@ export class CliInstallerService {
|
|||
* Gathers CLI status information, mutating the provided result object.
|
||||
* Split from getStatus() to enable overall timeout via Promise.race —
|
||||
* on timeout, getStatus() returns whatever fields were populated so far.
|
||||
*
|
||||
* Flow: binary resolve → --version (sequential) → Promise.all([auth, GCS]) (parallel)
|
||||
*/
|
||||
private async gatherStatus(ref: { current: CliInstallationStatus }): Promise<void> {
|
||||
const r = ref.current;
|
||||
|
|
@ -284,31 +295,84 @@ export class CliInstallerService {
|
|||
logger.warn('Failed to get CLI version:', getErrorMessage(err));
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
try {
|
||||
const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], {
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
env: buildChildEnv(),
|
||||
});
|
||||
const auth = JSON.parse(authStdout.trim()) as { loggedIn?: boolean; authMethod?: string };
|
||||
r.authLoggedIn = auth.loggedIn === true;
|
||||
r.authMethod = auth.authMethod ?? null;
|
||||
logger.info(`Auth status: loggedIn=${r.authLoggedIn}, method=${r.authMethod ?? 'null'}`);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to check auth status:', getErrorMessage(err));
|
||||
r.authLoggedIn = false;
|
||||
}
|
||||
// Auth and GCS version check are independent — run in parallel.
|
||||
// Both mutate `r` directly so partial results survive the outer timeout.
|
||||
await Promise.all([this.checkAuthStatus(binaryPath, r), this.fetchLatestVersion(r)]);
|
||||
} else {
|
||||
// No binary — still check latest version for "install" prompt
|
||||
await this.fetchLatestVersion(r);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check auth status with retry — covers stale lock files after Ctrl+C interruption.
|
||||
* Wrapped in its own timeout to prevent slow auth from blocking the overall status.
|
||||
* Mutates `r` directly so results survive even if the outer Promise.all hasn't resolved.
|
||||
*/
|
||||
|
||||
private async checkAuthStatus(binaryPath: string, result: CliInstallationStatus): Promise<void> {
|
||||
const doCheck = async (): Promise<void> => {
|
||||
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
|
||||
try {
|
||||
const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], {
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
env: buildChildEnv(),
|
||||
});
|
||||
const auth = JSON.parse(authStdout.trim()) as {
|
||||
loggedIn?: boolean;
|
||||
authMethod?: string;
|
||||
};
|
||||
result.authLoggedIn = auth.loggedIn === true; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
|
||||
result.authMethod = auth.authMethod ?? null; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
|
||||
logger.info(
|
||||
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` +
|
||||
(authAttempt > 1 ? ` (attempt ${authAttempt})` : '')
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (authAttempt < AUTH_STATUS_MAX_RETRIES) {
|
||||
logger.warn(
|
||||
`Auth status check failed (attempt ${authAttempt}/${AUTH_STATUS_MAX_RETRIES}), ` +
|
||||
`retrying in ${AUTH_STATUS_RETRY_DELAY_MS}ms: ${getErrorMessage(err)}`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, AUTH_STATUS_RETRY_DELAY_MS));
|
||||
} else {
|
||||
logger.warn(
|
||||
`Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${getErrorMessage(err)}`
|
||||
);
|
||||
result.authLoggedIn = false; // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Own timeout so slow auth doesn't eat the overall getStatus budget
|
||||
await Promise.race([
|
||||
doCheck(),
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
logger.warn(`Auth status check timed out after ${AUTH_TOTAL_TIMEOUT_MS}ms`);
|
||||
resolve();
|
||||
}, AUTH_TOTAL_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest CLI version from GCS and update the result object.
|
||||
*/
|
||||
private async fetchLatestVersion(result: CliInstallationStatus): Promise<void> {
|
||||
try {
|
||||
const latestRaw = await fetchText(`${GCS_BASE}/latest`);
|
||||
r.latestVersion = normalizeVersion(latestRaw);
|
||||
logger.info(`Latest CLI version: "${latestRaw.trim()}" → normalized: "${r.latestVersion}"`);
|
||||
result.latestVersion = normalizeVersion(latestRaw); // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
|
||||
logger.info(
|
||||
`Latest CLI version: "${latestRaw.trim()}" → normalized: "${result.latestVersion}"`
|
||||
);
|
||||
|
||||
if (r.installedVersion && r.latestVersion) {
|
||||
r.updateAvailable = isVersionOlder(r.installedVersion, r.latestVersion);
|
||||
if (result.installedVersion && result.latestVersion) {
|
||||
result.updateAvailable = isVersionOlder(result.installedVersion, result.latestVersion); // eslint-disable-line no-param-reassign -- intentional mutation of shared result object
|
||||
logger.info(
|
||||
`Update available: ${r.updateAvailable} (${r.installedVersion} → ${r.latestVersion})`
|
||||
`Update available: ${result.updateAvailable} (${result.installedVersion} → ${result.latestVersion})`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ export interface NotificationConfig {
|
|||
snoozeMinutes: number; // Default snooze duration
|
||||
/** Whether to include errors from subagent sessions */
|
||||
includeSubagentErrors: boolean;
|
||||
/** Whether to show native OS notifications when teammates send messages to the team lead */
|
||||
notifyOnLeadInbox: boolean;
|
||||
/** Whether to show native OS notifications when teammates send messages to you (the user) */
|
||||
notifyOnUserInbox: boolean;
|
||||
/** Whether to show native OS notifications when a task needs user clarification */
|
||||
notifyOnClarifications: boolean;
|
||||
/** Notification triggers - define when to generate notifications */
|
||||
triggers: NotificationTrigger[];
|
||||
}
|
||||
|
|
@ -184,6 +190,8 @@ export interface GeneralConfig {
|
|||
agentLanguage: string;
|
||||
autoExpandAIGroups: boolean;
|
||||
useNativeTitleBar: boolean;
|
||||
/** Paths manually added via "Select Folder" that persist across app restarts */
|
||||
customProjectPaths: string[];
|
||||
}
|
||||
|
||||
export interface DisplayConfig {
|
||||
|
|
@ -243,6 +251,9 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
snoozedUntil: null,
|
||||
snoozeMinutes: 30,
|
||||
includeSubagentErrors: true,
|
||||
notifyOnLeadInbox: false,
|
||||
notifyOnUserInbox: true,
|
||||
notifyOnClarifications: true,
|
||||
triggers: DEFAULT_TRIGGERS,
|
||||
},
|
||||
general: {
|
||||
|
|
@ -254,6 +265,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
agentLanguage: 'system',
|
||||
autoExpandAIGroups: false,
|
||||
useNativeTitleBar: false,
|
||||
customProjectPaths: [],
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
|
|
@ -407,6 +419,7 @@ export class ConfigManager {
|
|||
private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {
|
||||
const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);
|
||||
const loadedTriggers = loadedNotifications.triggers ?? [];
|
||||
|
||||
const mergedGeneral: GeneralConfig = {
|
||||
...DEFAULT_CONFIG.general,
|
||||
...(loaded.general ?? {}),
|
||||
|
|
@ -839,6 +852,58 @@ export class ConfigManager {
|
|||
this.saveConfig();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Custom Project Path Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Adds a custom project path (from "Select Folder" dialog).
|
||||
* Persisted across app restarts.
|
||||
* @param projectPath - Absolute filesystem path to the project
|
||||
*/
|
||||
addCustomProjectPath(projectPath: string): void {
|
||||
if (!projectPath || projectPath.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = path.normalize(projectPath.trim());
|
||||
if (!path.isAbsolute(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.general.customProjectPaths.includes(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.general.customProjectPaths.push(normalized);
|
||||
this.saveConfig();
|
||||
logger.info(`Custom project path added: ${normalized}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a custom project path.
|
||||
* @param projectPath - The path to remove
|
||||
*/
|
||||
removeCustomProjectPath(projectPath: string): void {
|
||||
const normalized = path.normalize(projectPath.trim());
|
||||
const index = this.config.general.customProjectPaths.indexOf(normalized);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.general.customProjectPaths.splice(index, 1);
|
||||
this.saveConfig();
|
||||
logger.info(`Custom project path removed: ${normalized}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all custom project paths.
|
||||
* @returns Array of absolute filesystem paths
|
||||
*/
|
||||
getCustomProjectPaths(): string[] {
|
||||
return [...this.config.general.customProjectPaths];
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// SSH Profile Management
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
* - Emit IPC events to renderer: notification:new, notification:updated
|
||||
*/
|
||||
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { type BrowserWindow, Notification } from 'electron';
|
||||
|
|
@ -394,11 +395,13 @@ export class NotificationManager extends EventEmitter {
|
|||
|
||||
const config = this.configManager.getConfig();
|
||||
|
||||
const iconPath = getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: 'Claude Code Error',
|
||||
subtitle: error.context.projectName,
|
||||
body: error.message.slice(0, 200),
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import {
|
||||
AUTO_CLAUDE_DIR,
|
||||
CCSWITCH_DIR,
|
||||
CLAUDE_CODE_DIR,
|
||||
CLAUDE_WORKTREES_DIR,
|
||||
CONDUCTOR_DIR,
|
||||
CURSOR_DIR,
|
||||
|
|
@ -242,6 +243,15 @@ class GitIdentityResolver {
|
|||
return parts[claudeWorktreesIdx + 1];
|
||||
}
|
||||
|
||||
// Pattern 6b: /.claude/worktrees/{name} (Claude Code CLI)
|
||||
const claudeCodeDirIdx = parts.indexOf(CLAUDE_CODE_DIR);
|
||||
if (claudeCodeDirIdx >= 0 && parts[claudeCodeDirIdx + 1] === WORKTREES_DIR) {
|
||||
// Repo is the directory containing .claude
|
||||
if (claudeCodeDirIdx > 0) {
|
||||
return parts[claudeCodeDirIdx - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 7: /.ccswitch/worktrees/{repo}/{name}
|
||||
const ccswitchIdx = parts.indexOf(CCSWITCH_DIR);
|
||||
if (ccswitchIdx >= 0 && parts[ccswitchIdx + 1] === WORKTREES_DIR) {
|
||||
|
|
@ -282,6 +292,11 @@ class GitIdentityResolver {
|
|||
if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) {
|
||||
return true;
|
||||
}
|
||||
// Pattern: .claude/worktrees/{name} (Claude Code CLI worktrees)
|
||||
const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR);
|
||||
if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) {
|
||||
return true;
|
||||
}
|
||||
if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) {
|
||||
// Subpaths in conductor/workspaces are worktrees
|
||||
const conductorIdx = parts.indexOf(CONDUCTOR_DIR);
|
||||
|
|
@ -569,6 +584,15 @@ class GitIdentityResolver {
|
|||
return 'ccswitch';
|
||||
}
|
||||
|
||||
// Pattern: claude-code (Claude Code CLI)
|
||||
// /Users/.../.claude/worktrees/{name}
|
||||
{
|
||||
const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR);
|
||||
if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) {
|
||||
return 'claude-code';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a standard git repo (only if filesystem exists)
|
||||
// For deleted repos, we'll return 'git' as fallback since we can't verify
|
||||
if (await fileExists(path.join(projectPath, '.git'))) {
|
||||
|
|
@ -668,6 +692,19 @@ class GitIdentityResolver {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'claude-code': {
|
||||
// Pattern: .claude/worktrees/{name}
|
||||
// Display: {name} (e.g., "editor-feature")
|
||||
const claudeCodeDirIdx = parts.indexOf(CLAUDE_CODE_DIR);
|
||||
if (claudeCodeDirIdx >= 0) {
|
||||
const worktreesIdx = parts.indexOf(WORKTREES_DIR, claudeCodeDirIdx);
|
||||
if (worktreesIdx >= 0 && parts[worktreesIdx + 1]) {
|
||||
return parts[worktreesIdx + 1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'git':
|
||||
// Standard git worktree - use branch or path-based name
|
||||
if (isMainWorktree) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||
import { structuredPatch } from 'diff';
|
||||
|
||||
import type { SnippetDiff } from '@shared/types';
|
||||
|
|
@ -36,6 +37,7 @@ export class HunkSnippetMatcher {
|
|||
if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue;
|
||||
const hunk = patch.hunks[hunkIdx];
|
||||
const snippetSet = new Set<number>();
|
||||
const strongMatches = new Set<number>();
|
||||
|
||||
// Reconstruct old/new side of hunk INCLUDING context lines.
|
||||
// Context lines (` ` prefix) are critical — without them, snippets whose
|
||||
|
|
@ -55,9 +57,18 @@ export class HunkSnippetMatcher {
|
|||
if (this.hasContentOverlap(snippet, oldSideContent, newSideContent)) {
|
||||
snippetSet.add(sIdx);
|
||||
}
|
||||
|
||||
// Strong match: contextHash matches the hunk's contextual fingerprint.
|
||||
// This reduces false positives when repeated patterns exist in a file.
|
||||
if (snippet.contextHash) {
|
||||
const h = computeDiffContextHash(oldSideContent, newSideContent);
|
||||
if (h === snippet.contextHash) {
|
||||
strongMatches.add(sIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mapping.set(hunkIdx, snippetSet);
|
||||
mapping.set(hunkIdx, strongMatches.size > 0 ? strongMatches : snippetSet);
|
||||
}
|
||||
|
||||
return mapping;
|
||||
|
|
@ -134,17 +145,20 @@ export class HunkSnippetMatcher {
|
|||
if (!snippet.newString && !snippet.oldString) return false;
|
||||
|
||||
if (snippet.type === 'write-new' || snippet.type === 'write-update') {
|
||||
// For Write: snippet.newString is the full file content — check if hunk's new side is within it
|
||||
if (snippet.newString && hunkNewSide) {
|
||||
return snippet.newString.includes(hunkNewSide);
|
||||
}
|
||||
// Full-file writes are intentionally excluded from localized hunk↔snippet matching.
|
||||
// They are handled by whole-file reject logic or hunk-level inverse patch.
|
||||
return false;
|
||||
}
|
||||
|
||||
// For Edit/MultiEdit: check if snippet falls within hunk's file range
|
||||
const matchesOld = snippet.oldString ? hunkOldSide.includes(snippet.oldString) : false;
|
||||
const matchesNew = snippet.newString ? hunkNewSide.includes(snippet.newString) : false;
|
||||
const hasOld = snippet.oldString.length > 0;
|
||||
const hasNew = snippet.newString.length > 0;
|
||||
const matchesOld = hasOld ? hunkOldSide.includes(snippet.oldString) : false;
|
||||
const matchesNew = hasNew ? hunkNewSide.includes(snippet.newString) : false;
|
||||
|
||||
return matchesOld || matchesNew;
|
||||
// Prefer stricter matching when both sides exist to avoid over-matching.
|
||||
if (hasOld && hasNew) return matchesOld && matchesNew;
|
||||
if (hasOld) return matchesOld;
|
||||
return matchesNew;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { applyPatch, structuredPatch } from 'diff';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { diff3Merge } from 'node-diff3';
|
||||
|
||||
import { HunkSnippetMatcher } from './HunkSnippetMatcher';
|
||||
|
|
@ -247,6 +248,68 @@ export class ReviewApplierService {
|
|||
const original = fileContent.originalFullContent;
|
||||
const modified = fileContent.modifiedFullContent;
|
||||
|
||||
const rejectedHunkIndices = Object.entries(decision.hunkDecisions)
|
||||
.filter(([, d]) => d === 'rejected')
|
||||
.map(([idx]) => parseInt(idx, 10));
|
||||
|
||||
const allHunksRejected =
|
||||
Object.keys(decision.hunkDecisions).length > 0 &&
|
||||
Object.values(decision.hunkDecisions).every((d) => d === 'rejected');
|
||||
const hasWriteNewSnippet = fileContent.snippets.some((s) => s.type === 'write-new');
|
||||
|
||||
// Special case: rejecting an entirely new file should remove it from disk.
|
||||
// IMPORTANT: Do NOT delete on partial reject — users may want to keep parts of the new file.
|
||||
const shouldDeleteNewFile =
|
||||
fileContent.isNewFile &&
|
||||
hasWriteNewSnippet &&
|
||||
original === '' &&
|
||||
(decision.fileDecision === 'rejected' || allHunksRejected);
|
||||
|
||||
if (shouldDeleteNewFile) {
|
||||
// If we have an expected modified baseline, guard against deleting a user-modified file.
|
||||
if (modified !== null) {
|
||||
const conflict = await this.checkConflict(decision.filePath, modified);
|
||||
if (conflict.hasConflict) {
|
||||
conflicts++;
|
||||
errors.push({
|
||||
filePath: decision.filePath,
|
||||
error:
|
||||
'File was modified since review was computed; refusing to delete new file automatically.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// No baseline — safest behavior is to only treat "already missing" as success.
|
||||
try {
|
||||
await readFile(decision.filePath, 'utf8');
|
||||
} catch {
|
||||
applied++;
|
||||
continue;
|
||||
}
|
||||
errors.push({
|
||||
filePath: decision.filePath,
|
||||
error: 'Cannot delete new file: expected modified content is unavailable.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(decision.filePath);
|
||||
applied++;
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes('ENOENT')) {
|
||||
applied++;
|
||||
} else {
|
||||
errors.push({
|
||||
filePath: decision.filePath,
|
||||
error: `Failed to delete new file: ${msg}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (original === null || modified === null) {
|
||||
errors.push({
|
||||
filePath: decision.filePath,
|
||||
|
|
@ -275,21 +338,27 @@ export class ReviewApplierService {
|
|||
}
|
||||
} else {
|
||||
// Partial reject — only specific hunks
|
||||
const rejectedHunkIndices = Object.entries(decision.hunkDecisions)
|
||||
.filter(([, d]) => d === 'rejected')
|
||||
.map(([idx]) => parseInt(idx, 10));
|
||||
|
||||
if (rejectedHunkIndices.length === 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const mappedRejected =
|
||||
decision.hunkContextHashes && Object.keys(decision.hunkContextHashes).length > 0
|
||||
? mapRejectedHunkIndicesByHash(
|
||||
original,
|
||||
modified,
|
||||
rejectedHunkIndices,
|
||||
decision.hunkContextHashes
|
||||
)
|
||||
: rejectedHunkIndices;
|
||||
|
||||
const result = await this.rejectHunks(
|
||||
request.teamName,
|
||||
decision.filePath,
|
||||
original,
|
||||
modified,
|
||||
rejectedHunkIndices,
|
||||
mappedRejected,
|
||||
fileContent.snippets
|
||||
);
|
||||
|
||||
|
|
@ -336,7 +405,12 @@ export class ReviewApplierService {
|
|||
hunkIndices: number[],
|
||||
snippets: SnippetDiff[]
|
||||
): RejectResult | null {
|
||||
const validSnippets = snippets.filter((s) => !s.isError);
|
||||
// Safety: never use full-file Write snippets for snippet-level rejection.
|
||||
// They are not localized, and matching a single hunk to a full-file write
|
||||
// can incorrectly delete/overwrite large parts of the file.
|
||||
const validSnippets = snippets.filter(
|
||||
(s) => !s.isError && s.type !== 'write-new' && s.type !== 'write-update'
|
||||
);
|
||||
if (validSnippets.length === 0) return null;
|
||||
|
||||
// Pass pre-filtered snippets — matcher returns indices relative to this array
|
||||
|
|
@ -347,6 +421,15 @@ export class ReviewApplierService {
|
|||
validSnippets
|
||||
);
|
||||
|
||||
// Safety: if any requested hunk maps ambiguously, do NOT attempt snippet-level replacement.
|
||||
// Fall back to hunk-level inverse patch which is positional and safer.
|
||||
for (const hunkIdx of hunkIndices) {
|
||||
const set = hunkToSnippets.get(hunkIdx);
|
||||
if (set?.size !== 1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all unique snippet indices to reject
|
||||
const snippetIndices = new Set<number>();
|
||||
for (const indices of hunkToSnippets.values()) {
|
||||
|
|
@ -450,6 +533,55 @@ export class ReviewApplierService {
|
|||
}
|
||||
}
|
||||
|
||||
function buildHunkHashIndexMap(original: string, modified: string): Map<string, number[]> {
|
||||
const patch = structuredPatch('file', 'file', original, modified);
|
||||
const hunks = patch.hunks ?? [];
|
||||
const map = new Map<string, number[]>();
|
||||
for (let i = 0; i < hunks.length; i++) {
|
||||
const hunk = hunks[i];
|
||||
const oldSideContent = hunk.lines
|
||||
.filter((l) => !l.startsWith('+'))
|
||||
.map((l) => l.slice(1))
|
||||
.join('\n');
|
||||
const newSideContent = hunk.lines
|
||||
.filter((l) => !l.startsWith('-'))
|
||||
.map((l) => l.slice(1))
|
||||
.join('\n');
|
||||
const hash = computeDiffContextHash(oldSideContent, newSideContent);
|
||||
const arr = map.get(hash);
|
||||
if (arr) arr.push(i);
|
||||
else map.set(hash, [i]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function mapRejectedHunkIndicesByHash(
|
||||
original: string,
|
||||
modified: string,
|
||||
rejectedIndices: number[],
|
||||
hunkContextHashes: Record<number, string>
|
||||
): number[] {
|
||||
const hashMap = buildHunkHashIndexMap(original, modified);
|
||||
const out = new Set<number>();
|
||||
|
||||
for (const idx of rejectedIndices) {
|
||||
const hash = hunkContextHashes[idx];
|
||||
if (!hash) {
|
||||
out.add(idx);
|
||||
continue;
|
||||
}
|
||||
const candidates = hashMap.get(hash);
|
||||
if (candidates?.length === 1) {
|
||||
out.add(candidates[0]);
|
||||
} else {
|
||||
// Ambiguous or missing — fall back to index to preserve prior behavior.
|
||||
out.add(idx);
|
||||
}
|
||||
}
|
||||
|
||||
return [...out].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// ── Module-level helpers ──
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ const logger = createLogger('ReviewDecisionStore');
|
|||
export interface ReviewDecisionsData {
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
/** filePath -> (hunkIndex -> contextHash) */
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +32,7 @@ export class ReviewDecisionStore {
|
|||
): Promise<{
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
} | null> {
|
||||
const filePath = this.getFilePath(teamName, scopeKey);
|
||||
|
||||
|
|
@ -62,8 +65,12 @@ export class ReviewDecisionStore {
|
|||
data.hunkDecisions && typeof data.hunkDecisions === 'object' ? data.hunkDecisions : {};
|
||||
const fileDecisions: Record<string, HunkDecision> =
|
||||
data.fileDecisions && typeof data.fileDecisions === 'object' ? data.fileDecisions : {};
|
||||
const hunkContextHashesByFile: Record<string, Record<number, string>> | undefined =
|
||||
data.hunkContextHashesByFile && typeof data.hunkContextHashesByFile === 'object'
|
||||
? data.hunkContextHashesByFile
|
||||
: undefined;
|
||||
|
||||
return { hunkDecisions, fileDecisions };
|
||||
return { hunkDecisions, fileDecisions, hunkContextHashesByFile };
|
||||
}
|
||||
|
||||
async save(
|
||||
|
|
@ -72,12 +79,14 @@ export class ReviewDecisionStore {
|
|||
data: {
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const payload: ReviewDecisionsData = {
|
||||
hunkDecisions: data.hunkDecisions,
|
||||
fileDecisions: data.fileDecisions,
|
||||
hunkContextHashesByFile: data.hunkContextHashesByFile,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await atomicWriteAsync(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { version as APP_VERSION } from '../../../../package.json';
|
|||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
const TOOL_FILE_NAME = 'teamctl.js';
|
||||
const TOOL_VERSION = 11;
|
||||
|
||||
function buildTeamCtlScript(version: string): string {
|
||||
const script = String.raw`#!/usr/bin/env node
|
||||
|
|
@ -196,10 +195,37 @@ function writeTask(taskPath, task) {
|
|||
return verify;
|
||||
}
|
||||
|
||||
function applyWorkIntervalsForStatusTransition(task, prevStatus, nextStatus, now) {
|
||||
var wasInProgress = prevStatus === 'in_progress';
|
||||
var isInProgress = nextStatus === 'in_progress';
|
||||
var intervals = Array.isArray(task.workIntervals) ? task.workIntervals.slice() : [];
|
||||
var last = intervals.length ? intervals[intervals.length - 1] : null;
|
||||
|
||||
if (!wasInProgress && isInProgress) {
|
||||
if (!last || typeof last.completedAt === 'string') {
|
||||
intervals.push({ startedAt: now });
|
||||
}
|
||||
} else if (wasInProgress && !isInProgress) {
|
||||
// Close the most recent open interval (if any).
|
||||
for (var i = intervals.length - 1; i >= 0; i--) {
|
||||
if (intervals[i] && typeof intervals[i].startedAt === 'string' && !intervals[i].completedAt) {
|
||||
intervals[i].completedAt = now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (intervals.length > 0) task.workIntervals = intervals;
|
||||
else delete task.workIntervals;
|
||||
}
|
||||
|
||||
function setTaskStatus(paths, taskId, status) {
|
||||
const normalized = normalizeStatus(status);
|
||||
if (!normalized) die('Invalid status: ' + String(status));
|
||||
const { taskPath, task } = readTask(paths, taskId);
|
||||
var prev = task.status;
|
||||
var now = nowIso();
|
||||
applyWorkIntervalsForStatusTransition(task, prev, normalized, now);
|
||||
task.status = normalized;
|
||||
writeTask(taskPath, task);
|
||||
}
|
||||
|
|
@ -245,6 +271,7 @@ function addTaskComment(paths, taskId, flags) {
|
|||
id: commentId,
|
||||
author: from,
|
||||
text: text,
|
||||
type: 'regular',
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
task.comments = existing.concat([comment]);
|
||||
|
|
@ -306,6 +333,103 @@ function updateHighwatermark(paths, taskId) {
|
|||
atomicWrite(hwmPath, String(taskId));
|
||||
}
|
||||
|
||||
function parseIdList(value) {
|
||||
if (!value || value === true) return [];
|
||||
var ids = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
|
||||
for (var k = 0; k < ids.length; k++) {
|
||||
if (!/^\d+$/.test(ids[k])) die('Invalid task ID in list: ' + ids[k]);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function taskExists(paths, taskId) {
|
||||
try {
|
||||
fs.accessSync(path.join(paths.tasksDir, String(taskId) + '.json'), fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function readTaskObject(paths, taskId) {
|
||||
var taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
|
||||
var t = readJson(taskPath, null);
|
||||
if (!t) die('Task not found: #' + taskId);
|
||||
return { task: t, taskPath: taskPath };
|
||||
}
|
||||
|
||||
function wouldCreateBlockCycle(paths, sourceId, targetId) {
|
||||
var visited = {};
|
||||
var stack = [String(targetId)];
|
||||
while (stack.length > 0) {
|
||||
var current = stack.pop();
|
||||
if (current === String(sourceId)) return true;
|
||||
if (visited[current]) continue;
|
||||
visited[current] = true;
|
||||
try {
|
||||
var t = readJson(path.join(paths.tasksDir, current + '.json'), null);
|
||||
if (t && Array.isArray(t.blockedBy)) {
|
||||
for (var i = 0; i < t.blockedBy.length; i++) stack.push(String(t.blockedBy[i]));
|
||||
}
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function linkTasks(paths, taskId, targetId, type) {
|
||||
var id = String(taskId), target = String(targetId);
|
||||
if (id === target) die('Cannot link a task to itself');
|
||||
if (!taskExists(paths, id)) die('Task not found: #' + id);
|
||||
if (!taskExists(paths, target)) die('Task not found: #' + target);
|
||||
|
||||
if (type === 'blocked-by') {
|
||||
if (wouldCreateBlockCycle(paths, id, target))
|
||||
die('Circular dependency: #' + target + ' already depends on #' + id);
|
||||
var refA = readTaskObject(paths, id);
|
||||
var bb = Array.isArray(refA.task.blockedBy) ? refA.task.blockedBy : [];
|
||||
if (!bb.includes(target)) { refA.task.blockedBy = bb.concat([target]); atomicWrite(refA.taskPath, JSON.stringify(refA.task, null, 2)); }
|
||||
var refB = readTaskObject(paths, target);
|
||||
var bl = Array.isArray(refB.task.blocks) ? refB.task.blocks : [];
|
||||
if (!bl.includes(id)) { refB.task.blocks = bl.concat([id]); atomicWrite(refB.taskPath, JSON.stringify(refB.task, null, 2)); }
|
||||
} else if (type === 'blocks') {
|
||||
linkTasks(paths, target, id, 'blocked-by');
|
||||
return;
|
||||
} else if (type === 'related') {
|
||||
var rA = readTaskObject(paths, id);
|
||||
var relA = Array.isArray(rA.task.related) ? rA.task.related : [];
|
||||
if (!relA.includes(target)) { rA.task.related = relA.concat([target]); atomicWrite(rA.taskPath, JSON.stringify(rA.task, null, 2)); }
|
||||
var rB = readTaskObject(paths, target);
|
||||
var relB = Array.isArray(rB.task.related) ? rB.task.related : [];
|
||||
if (!relB.includes(id)) { rB.task.related = relB.concat([id]); atomicWrite(rB.taskPath, JSON.stringify(rB.task, null, 2)); }
|
||||
}
|
||||
}
|
||||
|
||||
function unlinkTasks(paths, taskId, targetId, type) {
|
||||
var id = String(taskId), target = String(targetId);
|
||||
if (!taskExists(paths, id)) die('Task not found: #' + id);
|
||||
|
||||
if (type === 'blocked-by') {
|
||||
var refA = readTaskObject(paths, id);
|
||||
refA.task.blockedBy = (Array.isArray(refA.task.blockedBy) ? refA.task.blockedBy : []).filter(function(x) { return x !== target; });
|
||||
atomicWrite(refA.taskPath, JSON.stringify(refA.task, null, 2));
|
||||
if (taskExists(paths, target)) {
|
||||
var refB = readTaskObject(paths, target);
|
||||
refB.task.blocks = (Array.isArray(refB.task.blocks) ? refB.task.blocks : []).filter(function(x) { return x !== id; });
|
||||
atomicWrite(refB.taskPath, JSON.stringify(refB.task, null, 2));
|
||||
}
|
||||
} else if (type === 'blocks') {
|
||||
unlinkTasks(paths, target, id, 'blocked-by');
|
||||
return;
|
||||
} else if (type === 'related') {
|
||||
var rA = readTaskObject(paths, id);
|
||||
rA.task.related = (Array.isArray(rA.task.related) ? rA.task.related : []).filter(function(x) { return x !== target; });
|
||||
atomicWrite(rA.taskPath, JSON.stringify(rA.task, null, 2));
|
||||
if (taskExists(paths, target)) {
|
||||
var rB = readTaskObject(paths, target);
|
||||
rB.task.related = (Array.isArray(rB.task.related) ? rB.task.related : []).filter(function(x) { return x !== id; });
|
||||
atomicWrite(rB.taskPath, JSON.stringify(rB.task, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTask(paths, flags) {
|
||||
const subject = typeof flags.subject === 'string' ? flags.subject.trim() : '';
|
||||
if (!subject) die('Missing --subject');
|
||||
|
|
@ -317,7 +441,6 @@ function createTask(paths, flags) {
|
|||
: '';
|
||||
const owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined;
|
||||
const explicitStatus = typeof flags.status === 'string' ? flags.status : '';
|
||||
const status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending');
|
||||
const activeForm =
|
||||
typeof flags.activeForm === 'string'
|
||||
? flags.activeForm
|
||||
|
|
@ -325,6 +448,18 @@ function createTask(paths, flags) {
|
|||
? flags['active-form']
|
||||
: undefined;
|
||||
|
||||
var blockedByIds = parseIdList(flags['blocked-by']);
|
||||
var relatedIds = parseIdList(flags.related);
|
||||
// Default status rule:
|
||||
// - explicit --status always wins
|
||||
// - tasks with dependencies should start as pending, even if assigned (owner)
|
||||
// - otherwise, assigned tasks default to in_progress, unassigned to pending
|
||||
const status =
|
||||
normalizeStatus(explicitStatus) ||
|
||||
(blockedByIds.length > 0 ? 'pending' : owner ? 'in_progress' : 'pending');
|
||||
for (var v = 0; v < blockedByIds.length; v++) { if (!taskExists(paths, blockedByIds[v])) die('Blocked-by task not found: #' + blockedByIds[v]); }
|
||||
for (var w = 0; w < relatedIds.length; w++) { if (!taskExists(paths, relatedIds[w])) die('Related task not found: #' + relatedIds[w]); }
|
||||
|
||||
ensureDir(paths.tasksDir);
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
|
||||
let nextId;
|
||||
|
|
@ -333,6 +468,7 @@ function createTask(paths, flags) {
|
|||
while (true) {
|
||||
nextId = getNextTaskId(paths);
|
||||
taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
|
||||
var createdAt = nowIso();
|
||||
task = {
|
||||
id: nextId,
|
||||
subject,
|
||||
|
|
@ -341,8 +477,11 @@ function createTask(paths, flags) {
|
|||
owner,
|
||||
createdBy: from,
|
||||
status,
|
||||
createdAt: createdAt,
|
||||
workIntervals: status === 'in_progress' ? [{ startedAt: createdAt }] : undefined,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
blockedBy: blockedByIds,
|
||||
related: relatedIds.length > 0 ? relatedIds : undefined,
|
||||
};
|
||||
try {
|
||||
const fd = fs.openSync(taskPath, 'wx');
|
||||
|
|
@ -357,6 +496,20 @@ function createTask(paths, flags) {
|
|||
}
|
||||
}
|
||||
updateHighwatermark(paths, nextId);
|
||||
|
||||
// Set reverse links for blockedBy (target.blocks += nextId)
|
||||
for (var bi = 0; bi < blockedByIds.length; bi++) {
|
||||
var dep = readTaskObject(paths, blockedByIds[bi]);
|
||||
var depBl = Array.isArray(dep.task.blocks) ? dep.task.blocks : [];
|
||||
if (!depBl.includes(nextId)) { dep.task.blocks = depBl.concat([nextId]); atomicWrite(dep.taskPath, JSON.stringify(dep.task, null, 2)); }
|
||||
}
|
||||
// Set reverse links for related (bidirectional)
|
||||
for (var ri = 0; ri < relatedIds.length; ri++) {
|
||||
var rel = readTaskObject(paths, relatedIds[ri]);
|
||||
var relL = Array.isArray(rel.task.related) ? rel.task.related : [];
|
||||
if (!relL.includes(nextId)) { rel.task.related = relL.concat([nextId]); atomicWrite(rel.taskPath, JSON.stringify(rel.task, null, 2)); }
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
|
|
@ -447,18 +600,32 @@ function sendInboxMessage(paths, teamName, flags) {
|
|||
|
||||
function reviewApprove(paths, teamName, taskId, flags) {
|
||||
setKanbanColumn(paths, teamName, taskId, 'approved');
|
||||
const notify = flags.notify === true || flags['notify-owner'] === true;
|
||||
if (!notify) return;
|
||||
const { task } = readTask(paths, taskId);
|
||||
if (!task.owner) return;
|
||||
const { taskPath, task } = readTask(paths, taskId);
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
|
||||
const text = note
|
||||
|
||||
// Record review comment in task.comments
|
||||
var existing = Array.isArray(task.comments) ? task.comments : [];
|
||||
var reviewCommentId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
task.comments = existing.concat([{
|
||||
id: reviewCommentId,
|
||||
author: from,
|
||||
text: note || 'Approved',
|
||||
type: 'review_approved',
|
||||
createdAt: nowIso(),
|
||||
}]);
|
||||
writeTask(taskPath, task);
|
||||
|
||||
const notify = flags.notify === true || flags['notify-owner'] === true;
|
||||
if (!notify || !task.owner) return;
|
||||
const inboxText = note
|
||||
? 'Task #' + String(taskId) + ' approved.\n\n' + note
|
||||
: 'Task #' + String(taskId) + ' approved.';
|
||||
sendInboxMessage(paths, teamName, {
|
||||
to: task.owner,
|
||||
text,
|
||||
text: inboxText,
|
||||
summary: 'Approved #' + String(taskId),
|
||||
from,
|
||||
});
|
||||
|
|
@ -469,12 +636,29 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
|||
const { taskPath, task } = readTask(paths, taskId);
|
||||
if (!task.owner) die('No owner found for task ' + String(taskId));
|
||||
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||
|
||||
clearKanban(paths, teamName, taskId);
|
||||
var now = nowIso();
|
||||
applyWorkIntervalsForStatusTransition(task, task.status, 'in_progress', now);
|
||||
task.status = 'in_progress';
|
||||
|
||||
// Record review comment in task.comments
|
||||
var existing = Array.isArray(task.comments) ? task.comments : [];
|
||||
var reviewCommentId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
task.comments = existing.concat([{
|
||||
id: reviewCommentId,
|
||||
author: from,
|
||||
text: comment || 'Reviewer requested changes.',
|
||||
type: 'review_request',
|
||||
createdAt: now,
|
||||
}]);
|
||||
|
||||
writeTask(taskPath, task);
|
||||
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||
const text =
|
||||
const inboxText =
|
||||
'Task #' +
|
||||
String(taskId) +
|
||||
' needs fixes.\n\n' +
|
||||
|
|
@ -483,7 +667,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
|||
'Please fix and mark it as completed when ready.';
|
||||
sendInboxMessage(paths, teamName, {
|
||||
to: task.owner,
|
||||
text,
|
||||
text: inboxText,
|
||||
summary: 'Fix request for #' + String(taskId),
|
||||
from,
|
||||
});
|
||||
|
|
@ -639,6 +823,9 @@ function taskBriefing(paths, teamName, flags) {
|
|||
if (t.description && t.description !== t.subject) {
|
||||
parts.push(' Description: ' + t.description.slice(0, 500));
|
||||
}
|
||||
if (t.blocks && t.blocks.length > 0) {
|
||||
parts.push(' Blocks: ' + t.blocks.map(function(id) { return '#' + id; }).join(', '));
|
||||
}
|
||||
if (t.blockedBy && t.blockedBy.length > 0) {
|
||||
parts.push(' Blocked by: ' + t.blockedBy.map(function(id) { return '#' + id; }).join(', '));
|
||||
}
|
||||
|
|
@ -704,7 +891,13 @@ function printHelp() {
|
|||
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
|
||||
' node teamctl.js task complete <id> [--team <team>]',
|
||||
' node teamctl.js task start <id> [--team <team>]',
|
||||
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
|
||||
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--blocked-by 2,3] [--related 5] [--status ...] [--notify --from "member"] [--team <team>]',
|
||||
' node teamctl.js task link <id> --blocked-by <targetId> [--team <team>]',
|
||||
' node teamctl.js task link <id> --blocks <targetId> [--team <team>]',
|
||||
' node teamctl.js task link <id> --related <targetId> [--team <team>]',
|
||||
' node teamctl.js task unlink <id> --blocked-by <targetId> [--team <team>]',
|
||||
' node teamctl.js task unlink <id> --blocks <targetId> [--team <team>]',
|
||||
' node teamctl.js task unlink <id> --related <targetId> [--team <team>]',
|
||||
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--team <team>]',
|
||||
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
|
||||
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
|
||||
|
|
@ -877,6 +1070,30 @@ async function main() {
|
|||
taskBriefing(paths, teamName, args.flags);
|
||||
return;
|
||||
}
|
||||
if (action === 'link') {
|
||||
var linkId = rest[0] || args.flags.id;
|
||||
if (!linkId) die('Usage: task link <id> --blocked-by|--blocks|--related <targetId>');
|
||||
var linkBbF = args.flags['blocked-by'], linkBlF = args.flags.blocks, linkRelF = args.flags.related;
|
||||
var linkCnt = (linkBbF ? 1 : 0) + (linkBlF ? 1 : 0) + (linkRelF ? 1 : 0);
|
||||
if (linkCnt !== 1) die('Specify exactly one: --blocked-by, --blocks, or --related');
|
||||
var linkTp = linkBbF ? 'blocked-by' : linkBlF ? 'blocks' : 'related';
|
||||
var linkTv = linkBbF || linkBlF || linkRelF;
|
||||
linkTasks(paths, String(linkId), String(linkTv), linkTp);
|
||||
process.stdout.write('OK task #' + linkId + ' ' + linkTp + ' #' + linkTv + '\n');
|
||||
return;
|
||||
}
|
||||
if (action === 'unlink') {
|
||||
var unlinkId = rest[0] || args.flags.id;
|
||||
if (!unlinkId) die('Usage: task unlink <id> --blocked-by|--blocks|--related <targetId>');
|
||||
var unlinkBbF = args.flags['blocked-by'], unlinkBlF = args.flags.blocks, unlinkRelF = args.flags.related;
|
||||
var unlinkCnt = (unlinkBbF ? 1 : 0) + (unlinkBlF ? 1 : 0) + (unlinkRelF ? 1 : 0);
|
||||
if (unlinkCnt !== 1) die('Specify exactly one: --blocked-by, --blocks, or --related');
|
||||
var unlinkTp = unlinkBbF ? 'blocked-by' : unlinkBlF ? 'blocks' : 'related';
|
||||
var unlinkTv = unlinkBbF || unlinkBlF || unlinkRelF;
|
||||
unlinkTasks(paths, String(unlinkId), String(unlinkTv), unlinkTp);
|
||||
process.stdout.write('OK task #' + unlinkId + ' unlinked ' + unlinkTp + ' #' + unlinkTv + '\n');
|
||||
return;
|
||||
}
|
||||
die('Unknown task action: ' + String(action));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -146,14 +146,16 @@ export class TeamConfigReader {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Case-insensitive dedup: key is lowercase name, value keeps the original casing
|
||||
const memberMap = new Map<string, TeamSummaryMember>();
|
||||
|
||||
const addMember = (m: TeamMember): void => {
|
||||
const mergeMember = (m: TeamMember): void => {
|
||||
const name = m.name?.trim();
|
||||
if (!name) return;
|
||||
const existing = memberMap.get(name);
|
||||
memberMap.set(name, {
|
||||
name,
|
||||
const key = name.toLowerCase();
|
||||
const existing = memberMap.get(key);
|
||||
memberMap.set(key, {
|
||||
name: existing?.name ?? name,
|
||||
role: m.role?.trim() || existing?.role,
|
||||
color: m.color?.trim() || existing?.color,
|
||||
});
|
||||
|
|
@ -162,11 +164,24 @@ export class TeamConfigReader {
|
|||
if (config && Array.isArray(config.members)) {
|
||||
for (const member of config.members) {
|
||||
if (member && typeof member.name === 'string') {
|
||||
addMember(member);
|
||||
mergeMember(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also read members.meta.json — UI-created teams store members there,
|
||||
// and CLI-created teams may have additional members added via the UI.
|
||||
try {
|
||||
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||
for (const member of metaMembers) {
|
||||
if (!member.removedAt) {
|
||||
mergeMember(member);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort — don't fail listing if meta file is broken
|
||||
}
|
||||
|
||||
const members = Array.from(memberMap.values());
|
||||
const summary: TeamSummary = {
|
||||
teamName,
|
||||
|
|
|
|||
|
|
@ -783,6 +783,24 @@ export class TeamDataService {
|
|||
await this.taskWriter.setNeedsClarification(teamName, taskId, value);
|
||||
}
|
||||
|
||||
async addTaskRelationship(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
targetId: string,
|
||||
type: 'blockedBy' | 'blocks' | 'related'
|
||||
): Promise<void> {
|
||||
await this.taskWriter.addRelationship(teamName, taskId, targetId, type);
|
||||
}
|
||||
|
||||
async removeTaskRelationship(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
targetId: string,
|
||||
type: 'blockedBy' | 'blocks' | 'related'
|
||||
): Promise<void> {
|
||||
await this.taskWriter.removeRelationship(teamName, taskId, targetId, type);
|
||||
}
|
||||
|
||||
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
|
||||
const comment = await this.taskWriter.addComment(teamName, taskId, text);
|
||||
|
||||
|
|
@ -938,11 +956,15 @@ export class TeamDataService {
|
|||
await fs.promises.mkdir(tasksDir, { recursive: true });
|
||||
|
||||
const joinedAt = Date.now();
|
||||
const config = {
|
||||
const config: Record<string, unknown> = {
|
||||
name: request.displayName?.trim() || request.teamName,
|
||||
description: request.description?.trim() || undefined,
|
||||
color: request.color?.trim() || undefined,
|
||||
};
|
||||
if (request.cwd?.trim()) {
|
||||
config.projectPath = request.cwd.trim();
|
||||
config.projectPathHistory = [request.cwd.trim()];
|
||||
}
|
||||
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
await this.membersMetaStore.writeMembers(
|
||||
|
|
|
|||
|
|
@ -110,7 +110,12 @@ export class TeamMemberLogsFinder {
|
|||
async findLogsForTask(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: { owner?: string; status?: string }
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
}
|
||||
): Promise<MemberLogSummary[]> {
|
||||
const discovery = await this.discoverProjectSessions(teamName);
|
||||
if (!discovery) return [];
|
||||
|
|
@ -171,6 +176,56 @@ export class TeamMemberLogsFinder {
|
|||
options.owner.trim().length > 0;
|
||||
if (includeOwnerSessions) {
|
||||
const ownerLogs = await this.findMemberLogs(teamName, options.owner!.trim());
|
||||
|
||||
const TASK_LOG_INTERVAL_GRACE_MS = 10_000;
|
||||
const fallbackRecentMs = 30 * 60_000; // if caller doesn't supply intervals/since, avoid pulling in old owner history
|
||||
const now = Date.now();
|
||||
|
||||
const normalizedIntervals = Array.isArray(options?.intervals)
|
||||
? options.intervals
|
||||
.map((i) => {
|
||||
const startMs = Date.parse(i.startedAt);
|
||||
const endMsRaw =
|
||||
typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN;
|
||||
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
|
||||
return Number.isFinite(startMs) ? { startMs, endMs } : null;
|
||||
})
|
||||
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
|
||||
: [];
|
||||
|
||||
// Back-compat: single since timestamp -> treat as open interval.
|
||||
const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN;
|
||||
const sinceMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null;
|
||||
const effectiveIntervals =
|
||||
normalizedIntervals.length > 0
|
||||
? normalizedIntervals
|
||||
: sinceMs != null
|
||||
? [{ startMs: sinceMs, endMs: null }]
|
||||
: [];
|
||||
|
||||
const overlapsAnyInterval = (logStartMs: number, logEndMs: number): boolean => {
|
||||
for (const it of effectiveIntervals) {
|
||||
const start = it.startMs - TASK_LOG_INTERVAL_GRACE_MS;
|
||||
const end = (it.endMs ?? now) + TASK_LOG_INTERVAL_GRACE_MS;
|
||||
if (logStartMs <= end && logEndMs >= start) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const filteredOwnerLogs = ownerLogs.filter((log) => {
|
||||
if (log.isOngoing) return true;
|
||||
const startMs = new Date(log.startTime).getTime();
|
||||
if (!Number.isFinite(startMs)) return false;
|
||||
const durationMs =
|
||||
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
|
||||
const endMs = startMs + durationMs;
|
||||
|
||||
if (effectiveIntervals.length > 0) {
|
||||
return overlapsAnyInterval(startMs, endMs);
|
||||
}
|
||||
|
||||
return startMs >= now - fallbackRecentMs;
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
for (const log of results) {
|
||||
const key =
|
||||
|
|
@ -179,7 +234,7 @@ export class TeamMemberLogsFinder {
|
|||
: `lead:${log.sessionId}`;
|
||||
seen.add(key);
|
||||
}
|
||||
for (const log of ownerLogs) {
|
||||
for (const log of filteredOwnerLogs) {
|
||||
const key =
|
||||
log.kind === 'subagent'
|
||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||
|
|
@ -409,12 +464,17 @@ export class TeamMemberLogsFinder {
|
|||
const patterns: RegExp[] = [
|
||||
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
|
||||
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
|
||||
new RegExp(`#${escaped}\\b`),
|
||||
];
|
||||
if (numericTaskId) {
|
||||
patterns.push(
|
||||
new RegExp(`"task_id"\\s*:\\s*${numericTaskId}\\b`),
|
||||
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`)
|
||||
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`),
|
||||
// Support teamctl command lines (may appear in tool output).
|
||||
// Example: node ".../teamctl.js" --team "t" task start 10
|
||||
new RegExp(
|
||||
`\\bteamctl(?:\\.js)?\\b.{0,250}\\b(?:task|review)\\b.{0,250}\\b${numericTaskId}\\b`,
|
||||
'i'
|
||||
)
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
|||
const SHELL_ENV_TIMEOUT_MS = 12000;
|
||||
const CLI_PREPARE_TIMEOUT_MS = 10000;
|
||||
const PREFLIGHT_TIMEOUT_MS = 30000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
const KEYCHAIN_TIMEOUT_MS = 5000;
|
||||
const FS_MONITOR_POLL_MS = 2000;
|
||||
const TASK_WAIT_FALLBACK_MS = 15_000;
|
||||
|
|
@ -144,6 +146,18 @@ interface ProvisioningRun {
|
|||
detectedSessionId: string | null;
|
||||
/** Lead process activity: 'active' during turn processing, 'idle' waiting for input, 'offline' after exit. */
|
||||
leadActivityState: LeadActivityState;
|
||||
/** Whether an auth failure retry was already attempted for this run. */
|
||||
authFailureRetried: boolean;
|
||||
/** Set to true while auth-failure respawn is in progress to prevent duplicate handling. */
|
||||
authRetryInProgress: boolean;
|
||||
/** Saved spawn context for auth-failure respawn. */
|
||||
spawnContext: {
|
||||
claudePath: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
prompt: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
|
@ -336,6 +350,9 @@ function buildTaskStatusProtocol(teamName: string): string {
|
|||
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
|
||||
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "<your-name>"
|
||||
d) Do NOT set clarification to "user" yourself — only the team lead escalates to the user.
|
||||
11. DEPENDENCY AWARENESS:
|
||||
When your task has blockedBy dependencies, check if they are completed before starting.
|
||||
When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed.
|
||||
Failure to follow this protocol means the task board will show incorrect status.`);
|
||||
}
|
||||
|
||||
|
|
@ -358,11 +375,31 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
|||
`Internal task board tooling (teamctl.js):`,
|
||||
`- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).`,
|
||||
``,
|
||||
`Parallelization guideline (IMPORTANT):`,
|
||||
`- If a task is genuinely parallelizable, split it into multiple smaller tasks owned by different members.`,
|
||||
` - Prefer splitting by independent deliverables (e.g. frontend/backend, API/UI, parsing/rendering, tests/docs) rather than arbitrary slices.`,
|
||||
` - Use --blocked-by only when one piece truly cannot start without another; otherwise link with --related.`,
|
||||
` - Do NOT split when work is inherently sequential, requires one person to keep consistent context, or the overhead would exceed the benefit.`,
|
||||
` - When splitting, make each task have a clear completion criterion and a single accountable owner.`,
|
||||
``,
|
||||
`Task board operations — use teamctl.js via Bash:`,
|
||||
`- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"`,
|
||||
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
|
||||
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
|
||||
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
|
||||
`- Create with deps (blocked work MUST be pending): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --status pending --owner "<member>" --notify --from "${leadName}"`,
|
||||
`- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --blocked-by <targetId>`,
|
||||
`- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --related <targetId>`,
|
||||
`- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink <id> --blocked-by <targetId>`,
|
||||
``,
|
||||
`Dependency guidelines:`,
|
||||
`- Use --blocked-by when a task cannot start until another is done.`,
|
||||
`- If you set --blocked-by, create the task in pending (use --status pending). Do NOT put blocked tasks into in_progress.`,
|
||||
`- Use --related to link related work (e.g. frontend + backend) without blocking.`,
|
||||
`- Review tasks: Prefer NOT creating a separate "review task". Reviews apply to the work task (#X) via: review approve/request-changes #X.`,
|
||||
` - If you must create a separate review reminder/assignment task, keep it pending and link it to #X with --related (and optionally --blocked-by #X if it truly cannot start yet).`,
|
||||
` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`,
|
||||
`- Avoid over-specifying. Only add dependencies when execution order matters.`,
|
||||
``,
|
||||
`Notification policy:`,
|
||||
`- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`,
|
||||
|
|
@ -431,7 +468,10 @@ function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string
|
|||
|
||||
const lines = activeTasks.map((t) => {
|
||||
const desc = t.description ? ` — ${t.description.slice(0, 120)}` : '';
|
||||
return ` - #${t.id} [${t.status}] ${t.subject}${desc}`;
|
||||
const deps = t.blockedBy?.length
|
||||
? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]`
|
||||
: '';
|
||||
return ` - #${t.id} [${t.status}] ${t.subject}${deps}${desc}`;
|
||||
});
|
||||
return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`;
|
||||
}
|
||||
|
|
@ -446,7 +486,10 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
|||
const lines = active.map((t) => {
|
||||
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
|
||||
const desc = t.description ? ` — ${t.description.slice(0, 120)}` : '';
|
||||
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${desc}`;
|
||||
const deps = t.blockedBy?.length
|
||||
? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]`
|
||||
: '';
|
||||
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${deps}${desc}`;
|
||||
});
|
||||
return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`;
|
||||
}
|
||||
|
|
@ -521,6 +564,15 @@ ${processRegistration}
|
|||
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
||||
- Prefer fewer, broader tasks over many micro-tasks.
|
||||
- Avoid duplicate notifications for the same assignment.
|
||||
- When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by.
|
||||
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
|
||||
- Review guidance:
|
||||
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
|
||||
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
|
||||
- Use --related to connect it to #X (non-blocking link).
|
||||
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
|
||||
- There is no automatic status transition when dependencies resolve — the owner must explicitly start it (task start / set-status in_progress) when ready.
|
||||
- Use --related to connect tasks working on the same feature without blocking.
|
||||
|
||||
4) After all steps, output a short summary.
|
||||
|
||||
|
|
@ -872,6 +924,231 @@ export class TeamProvisioningService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects auth failure keywords in stderr/stdout during provisioning.
|
||||
* On first detection: kills process, waits, and respawns automatically.
|
||||
* On second detection (after retry): fails fast with a clear error.
|
||||
*/
|
||||
private handleAuthFailureInOutput(run: ProvisioningRun, text: string, source: string): void {
|
||||
if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return;
|
||||
if (!this.isAuthFailureWarning(text)) return;
|
||||
|
||||
if (!run.authFailureRetried) {
|
||||
logger.warn(
|
||||
`[${run.teamName}] Auth failure detected in ${source} during provisioning — ` +
|
||||
`will kill process and retry after ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms`
|
||||
);
|
||||
run.authRetryInProgress = true;
|
||||
void this.respawnAfterAuthFailure(run);
|
||||
} else {
|
||||
logger.error(`[${run.teamName}] Auth failure detected in ${source} after retry — giving up`);
|
||||
run.processKilled = true;
|
||||
killProcessTree(run.child);
|
||||
const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', {
|
||||
error:
|
||||
'Claude CLI is not authenticated. Run `claude` in a terminal to complete login, ' +
|
||||
'or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN and try again.',
|
||||
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
||||
});
|
||||
run.onProgress(progress);
|
||||
this.cleanupRun(run);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills the current process, waits for lock release, and respawns with saved context.
|
||||
* Reattaches all stream listeners and resends the prompt.
|
||||
*/
|
||||
private async respawnAfterAuthFailure(run: ProvisioningRun): Promise<void> {
|
||||
const ctx = run.spawnContext;
|
||||
if (!ctx) {
|
||||
logger.error(`[${run.teamName}] Cannot respawn — no spawn context saved`);
|
||||
run.authRetryInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Tear down current process without full cleanupRun (keep run alive)
|
||||
if (run.timeoutHandle) {
|
||||
clearTimeout(run.timeoutHandle);
|
||||
run.timeoutHandle = null;
|
||||
}
|
||||
this.stopFilesystemMonitor(run);
|
||||
if (run.child) {
|
||||
run.child.stdout?.removeAllListeners('data');
|
||||
run.child.stderr?.removeAllListeners('data');
|
||||
run.child.removeAllListeners('error');
|
||||
run.child.removeAllListeners('exit');
|
||||
killProcessTree(run.child);
|
||||
run.child = null;
|
||||
}
|
||||
|
||||
// Reset buffers for fresh attempt
|
||||
run.stdoutBuffer = '';
|
||||
run.stderrBuffer = '';
|
||||
run.authFailureRetried = true;
|
||||
|
||||
updateProgress(run, 'spawning', 'Auth failed — retrying after short delay');
|
||||
run.onProgress(run.progress);
|
||||
|
||||
await sleep(PREFLIGHT_AUTH_RETRY_DELAY_MS);
|
||||
|
||||
if (run.cancelRequested) {
|
||||
run.authRetryInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Respawn
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawnCli(ctx.claudePath, ctx.args, {
|
||||
cwd: ctx.cwd,
|
||||
env: { ...ctx.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
run.authRetryInProgress = false;
|
||||
const progress = updateProgress(run, 'failed', 'Failed to respawn Claude CLI', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
run.onProgress(progress);
|
||||
this.cleanupRun(run);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${run.teamName}] Respawned CLI process after auth failure (pid=${child.pid ?? '?'})`
|
||||
);
|
||||
run.child = child;
|
||||
run.authRetryInProgress = false;
|
||||
|
||||
updateProgress(run, 'spawning', 'CLI respawned — sending prompt', {
|
||||
pid: child.pid ?? undefined,
|
||||
});
|
||||
run.onProgress(run.progress);
|
||||
|
||||
// Resend prompt
|
||||
if (child.stdin?.writable) {
|
||||
const message = JSON.stringify({
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: ctx.prompt }],
|
||||
},
|
||||
});
|
||||
child.stdin.write(message + '\n');
|
||||
}
|
||||
|
||||
// Reattach stdout handler
|
||||
this.attachStdoutHandler(run);
|
||||
|
||||
// Reattach stderr handler
|
||||
this.attachStderrHandler(run);
|
||||
|
||||
// Restart filesystem monitor for createTeam (launch skips it)
|
||||
if (!run.isLaunch) {
|
||||
this.startFilesystemMonitor(run, run.request);
|
||||
} else {
|
||||
updateProgress(run, 'monitoring', 'CLI running — reconnecting with teammates');
|
||||
run.onProgress(run.progress);
|
||||
}
|
||||
|
||||
// Restart timeout
|
||||
run.timeoutHandle = setTimeout(() => {
|
||||
if (!run.processKilled && !run.provisioningComplete) {
|
||||
run.processKilled = true;
|
||||
run.finalizingByTimeout = true;
|
||||
void (async () => {
|
||||
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
|
||||
run.child?.stdin?.end();
|
||||
killProcessTree(run.child);
|
||||
if (readyOnTimeout) return;
|
||||
|
||||
const hint = run.isLaunch ? ' (launch)' : '';
|
||||
const progress = updateProgress(run, 'failed', `Timed out waiting for CLI${hint}`, {
|
||||
error: `Timed out waiting for CLI${hint}.`,
|
||||
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
||||
});
|
||||
run.onProgress(progress);
|
||||
this.cleanupRun(run);
|
||||
})();
|
||||
}
|
||||
}, RUN_TIMEOUT_MS);
|
||||
|
||||
child.once('error', (error) => {
|
||||
const hint = run.isLaunch ? ' (launch)' : '';
|
||||
const progress = updateProgress(run, 'failed', `Failed to start Claude CLI${hint}`, {
|
||||
error: error.message,
|
||||
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
||||
});
|
||||
run.onProgress(progress);
|
||||
this.cleanupRun(run);
|
||||
});
|
||||
|
||||
child.once('exit', (code) => {
|
||||
void this.handleProcessExit(run, code);
|
||||
});
|
||||
}
|
||||
|
||||
/** Attaches the stdout stream-json parser to the current child process. */
|
||||
private attachStdoutHandler(run: ProvisioningRun): void {
|
||||
const child = run.child;
|
||||
if (!child?.stdout) return;
|
||||
|
||||
let stdoutLineBuf = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf8');
|
||||
run.stdoutBuffer += text;
|
||||
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
|
||||
run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT);
|
||||
}
|
||||
|
||||
// Parse stream-json lines (newline-delimited JSON)
|
||||
stdoutLineBuf += text;
|
||||
const lines = stdoutLineBuf.split('\n');
|
||||
stdoutLineBuf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const msg = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
this.handleStreamJsonMessage(run, msg);
|
||||
} catch {
|
||||
// Not valid JSON — check for auth failure in raw text output
|
||||
this.handleAuthFailureInOutput(run, trimmed, 'stdout');
|
||||
}
|
||||
}
|
||||
|
||||
const currentTs = Date.now();
|
||||
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
|
||||
run.lastLogProgressAt = currentTs;
|
||||
emitLogsProgress(run);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Attaches the stderr handler with auth failure detection. */
|
||||
private attachStderrHandler(run: ProvisioningRun): void {
|
||||
const child = run.child;
|
||||
if (!child?.stderr) return;
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf8');
|
||||
run.stderrBuffer += text;
|
||||
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
|
||||
run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT);
|
||||
}
|
||||
|
||||
// Detect auth failure early instead of waiting for 5-minute timeout
|
||||
this.handleAuthFailureInOutput(run, text, 'stderr');
|
||||
|
||||
const currentTs = Date.now();
|
||||
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
|
||||
run.lastLogProgressAt = currentTs;
|
||||
emitLogsProgress(run);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createTeam(
|
||||
request: TeamCreateRequest,
|
||||
onProgress: (progress: TeamProvisioningProgress) => void
|
||||
|
|
@ -924,6 +1201,9 @@ export class TeamProvisioningService {
|
|||
provisioningOutputParts: [],
|
||||
detectedSessionId: null,
|
||||
leadActivityState: 'active',
|
||||
authFailureRetried: false,
|
||||
authRetryInProgress: false,
|
||||
spawnContext: null,
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -948,30 +1228,25 @@ export class TeamProvisioningService {
|
|||
'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.'
|
||||
);
|
||||
}
|
||||
const spawnArgs = [
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--disallowedTools',
|
||||
'TeamDelete,TodoWrite',
|
||||
'--dangerously-skip-permissions',
|
||||
...(request.model ? ['--model', request.model] : []),
|
||||
];
|
||||
try {
|
||||
child = spawnCli(
|
||||
claudePath,
|
||||
[
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--disallowedTools',
|
||||
'TeamDelete,TodoWrite',
|
||||
'--dangerously-skip-permissions',
|
||||
...(request.model ? ['--model', request.model] : []),
|
||||
],
|
||||
{
|
||||
cwd: request.cwd,
|
||||
env: {
|
||||
...shellEnv,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
child = spawnCli(claudePath, spawnArgs, {
|
||||
cwd: request.cwd,
|
||||
env: { ...shellEnv },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
this.runs.delete(runId);
|
||||
this.activeByTeam.delete(request.teamName);
|
||||
|
|
@ -981,6 +1256,13 @@ export class TeamProvisioningService {
|
|||
updateProgress(run, 'spawning', 'Starting Claude CLI process', { pid: child.pid ?? undefined });
|
||||
run.onProgress(run.progress);
|
||||
run.child = child;
|
||||
run.spawnContext = {
|
||||
claudePath,
|
||||
args: spawnArgs,
|
||||
cwd: request.cwd,
|
||||
env: { ...shellEnv },
|
||||
prompt,
|
||||
};
|
||||
|
||||
// Send provisioning prompt as first stream-json message (SDKUserMessage format)
|
||||
if (child.stdin?.writable) {
|
||||
|
|
@ -994,51 +1276,8 @@ export class TeamProvisioningService {
|
|||
child.stdin.write(message + '\n');
|
||||
}
|
||||
|
||||
if (child.stdout) {
|
||||
let stdoutLineBuf = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf8');
|
||||
run.stdoutBuffer += text;
|
||||
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
|
||||
run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT);
|
||||
}
|
||||
|
||||
// Parse stream-json lines (newline-delimited JSON)
|
||||
stdoutLineBuf += text;
|
||||
const lines = stdoutLineBuf.split('\n');
|
||||
stdoutLineBuf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const msg = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
this.handleStreamJsonMessage(run, msg);
|
||||
} catch {
|
||||
// Not valid JSON — raw text output, ignore
|
||||
}
|
||||
}
|
||||
|
||||
const currentTs = Date.now();
|
||||
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
|
||||
run.lastLogProgressAt = currentTs;
|
||||
emitLogsProgress(run);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
run.stderrBuffer += chunk.toString('utf8');
|
||||
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
|
||||
run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT);
|
||||
}
|
||||
const currentTs = Date.now();
|
||||
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
|
||||
run.lastLogProgressAt = currentTs;
|
||||
emitLogsProgress(run);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.attachStdoutHandler(run);
|
||||
this.attachStderrHandler(run);
|
||||
|
||||
// Filesystem-based progress monitor: actively polls team files instead
|
||||
// of relying on stdout (which only arrives at the end in text mode).
|
||||
|
|
@ -1126,23 +1365,35 @@ export class TeamProvisioningService {
|
|||
configParsed.leadSessionId.trim().length > 0
|
||||
) {
|
||||
const candidateId = configParsed.leadSessionId.trim();
|
||||
const projectPath =
|
||||
const storedProjectPath =
|
||||
typeof configParsed.projectPath === 'string' &&
|
||||
configParsed.projectPath.trim().length > 0
|
||||
? configParsed.projectPath.trim()
|
||||
: request.cwd;
|
||||
const projectId = encodePath(projectPath);
|
||||
const baseDir = extractBaseDir(projectId);
|
||||
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
|
||||
if (await this.pathExists(jsonlPath)) {
|
||||
previousSessionId = candidateId;
|
||||
: null;
|
||||
|
||||
// Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/).
|
||||
// If the project path changed, the old session JSONL won't be found by the CLI
|
||||
// at the new project directory. Skip resume to avoid passing an invalid --resume arg.
|
||||
if (storedProjectPath && path.resolve(storedProjectPath) !== path.resolve(request.cwd)) {
|
||||
logger.info(
|
||||
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
|
||||
`[${request.teamName}] Project path changed: ${storedProjectPath} → ${request.cwd}. ` +
|
||||
`Skipping session resume — sessions are per-project.`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
|
||||
);
|
||||
const resumeProjectPath = storedProjectPath ?? request.cwd;
|
||||
const projectId = encodePath(resumeProjectPath);
|
||||
const baseDir = extractBaseDir(projectId);
|
||||
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
|
||||
if (await this.pathExists(jsonlPath)) {
|
||||
previousSessionId = candidateId;
|
||||
logger.info(
|
||||
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -1156,6 +1407,11 @@ export class TeamProvisioningService {
|
|||
// Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names.
|
||||
await this.normalizeTeamConfigForLaunch(request.teamName, configRaw);
|
||||
|
||||
// Update projectPath in config IMMEDIATELY so TeamDetailView shows the correct path
|
||||
// even if provisioning is interrupted or the user stops the team early.
|
||||
// If launch fails, restorePrelaunchConfig() will revert to the backup (old projectPath).
|
||||
await this.updateConfigProjectPath(request.teamName, request.cwd);
|
||||
|
||||
let claudePath: string | null;
|
||||
try {
|
||||
await ensureCwdExists(request.cwd);
|
||||
|
|
@ -1207,6 +1463,9 @@ export class TeamProvisioningService {
|
|||
provisioningOutputParts: [],
|
||||
detectedSessionId: null,
|
||||
leadActivityState: 'active',
|
||||
authFailureRetried: false,
|
||||
authRetryInProgress: false,
|
||||
spawnContext: null,
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -1291,6 +1550,13 @@ export class TeamProvisioningService {
|
|||
});
|
||||
run.onProgress(run.progress);
|
||||
run.child = child;
|
||||
run.spawnContext = {
|
||||
claudePath,
|
||||
args: launchArgs,
|
||||
cwd: request.cwd,
|
||||
env: { ...shellEnv },
|
||||
prompt,
|
||||
};
|
||||
|
||||
// Send launch prompt
|
||||
if (child.stdin?.writable) {
|
||||
|
|
@ -1304,50 +1570,8 @@ export class TeamProvisioningService {
|
|||
child.stdin.write(message + '\n');
|
||||
}
|
||||
|
||||
if (child.stdout) {
|
||||
let stdoutLineBuf = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf8');
|
||||
run.stdoutBuffer += text;
|
||||
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
|
||||
run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT);
|
||||
}
|
||||
|
||||
stdoutLineBuf += text;
|
||||
const lines = stdoutLineBuf.split('\n');
|
||||
stdoutLineBuf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const msg = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
this.handleStreamJsonMessage(run, msg);
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
const currentTs = Date.now();
|
||||
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
|
||||
run.lastLogProgressAt = currentTs;
|
||||
emitLogsProgress(run);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
run.stderrBuffer += chunk.toString('utf8');
|
||||
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
|
||||
run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT);
|
||||
}
|
||||
const currentTs = Date.now();
|
||||
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
|
||||
run.lastLogProgressAt = currentTs;
|
||||
emitLogsProgress(run);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.attachStdoutHandler(run);
|
||||
this.attachStderrHandler(run);
|
||||
|
||||
// For launch, skip the filesystem monitor — files (config, inboxes, tasks)
|
||||
// already exist from the previous run and would trigger immediate false
|
||||
|
|
@ -2217,6 +2441,13 @@ export class TeamProvisioningService {
|
|||
if (run.progress.state === 'failed' || run.cancelRequested) {
|
||||
return;
|
||||
}
|
||||
// Skip if respawn after auth failure is in progress — the old process is being replaced
|
||||
if (run.authRetryInProgress) {
|
||||
logger.info(
|
||||
`[${run.teamName}] Process exited (code ${code ?? '?'}) during auth-failure respawn — ignoring`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// === Process exited AFTER provisioning completed ===
|
||||
// This means the team went offline (crash, kill, or natural exit).
|
||||
|
|
@ -2610,6 +2841,35 @@ export class TeamProvisioningService {
|
|||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately update projectPath in config.json at launch start, before CLI spawn.
|
||||
* Ensures TeamDetailView shows the correct project path even if provisioning
|
||||
* is interrupted. On failure, restorePrelaunchConfig() reverts to the backup.
|
||||
*/
|
||||
private async updateConfigProjectPath(teamName: string, cwd: string): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
try {
|
||||
const raw = await fs.promises.readFile(configPath, 'utf8');
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
config.projectPath = cwd;
|
||||
|
||||
const pathHistory = Array.isArray(config.projectPathHistory)
|
||||
? (config.projectPathHistory as string[]).filter((p) => typeof p === 'string' && p !== cwd)
|
||||
: [];
|
||||
pathHistory.push(cwd);
|
||||
config.projectPathHistory = pathHistory.slice(-500);
|
||||
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
logger.info(`[${teamName}] Updated config.projectPath immediately: ${cwd}`);
|
||||
} catch (error) {
|
||||
// Non-fatal: updateConfigPostLaunch will update it later if provisioning succeeds.
|
||||
logger.warn(
|
||||
`[${teamName}] Failed to update projectPath early: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single atomic read-mutate-write for post-launch config updates.
|
||||
* Combines session history append and projectPath update to avoid
|
||||
|
|
@ -3119,40 +3379,67 @@ export class TeamProvisioningService {
|
|||
throw new Error(`Failed to warm up Claude CLI: ${errorText}`);
|
||||
}
|
||||
|
||||
// Stage 2: verify `-p` mode auth actually works
|
||||
let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null;
|
||||
try {
|
||||
pingProbe = await this.spawnProbe(
|
||||
claudePath,
|
||||
['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'],
|
||||
cwd,
|
||||
env,
|
||||
PREFLIGHT_TIMEOUT_MS
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
warning:
|
||||
'Preflight check for `claude -p` did not complete. ' +
|
||||
`Proceeding anyway. Details: ${message}`,
|
||||
};
|
||||
}
|
||||
// Stage 2: verify `-p` mode auth actually works (with retry for stale locks after Ctrl+C)
|
||||
for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) {
|
||||
let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null;
|
||||
try {
|
||||
pingProbe = await this.spawnProbe(
|
||||
claudePath,
|
||||
['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'],
|
||||
cwd,
|
||||
env,
|
||||
PREFLIGHT_TIMEOUT_MS
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
||||
logger.warn(
|
||||
`Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
||||
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, PREFLIGHT_AUTH_RETRY_DELAY_MS));
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
warning:
|
||||
'Preflight check for `claude -p` did not complete. ' +
|
||||
`Proceeding anyway. Details: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr);
|
||||
const lowerOutput = combinedOutput.toLowerCase();
|
||||
const isAuthFailure =
|
||||
lowerOutput.includes('not logged in') ||
|
||||
lowerOutput.includes('please run /login') ||
|
||||
lowerOutput.includes('missing api key') ||
|
||||
lowerOutput.includes('invalid api key');
|
||||
const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr);
|
||||
const lowerOutput = combinedOutput.toLowerCase();
|
||||
const isAuthFailure =
|
||||
lowerOutput.includes('not logged in') ||
|
||||
lowerOutput.includes('please run /login') ||
|
||||
lowerOutput.includes('missing api key') ||
|
||||
lowerOutput.includes('invalid api key');
|
||||
|
||||
if (isAuthFailure || pingProbe.exitCode !== 0) {
|
||||
const hint = isAuthFailure
|
||||
? 'Claude CLI `-p` mode is not authenticated. ' +
|
||||
'Set ANTHROPIC_API_KEY, or run `claude setup-token` to generate a long-lived OAuth token, ' +
|
||||
'then export it as CLAUDE_CODE_OAUTH_TOKEN.'
|
||||
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||
return { warning: hint };
|
||||
if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
||||
logger.warn(
|
||||
`Preflight auth failure detected (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
||||
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms — likely stale locks from interrupted process`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, PREFLIGHT_AUTH_RETRY_DELAY_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAuthFailure || pingProbe.exitCode !== 0) {
|
||||
const hint = isAuthFailure
|
||||
? 'Claude CLI `-p` mode is not authenticated. ' +
|
||||
'Set ANTHROPIC_API_KEY, or run `claude setup-token` to generate a long-lived OAuth token, ' +
|
||||
'then export it as CLAUDE_CODE_OAUTH_TOKEN.' +
|
||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||
return { warning: hint };
|
||||
}
|
||||
|
||||
if (attempt > 1) {
|
||||
logger.info(
|
||||
`Preflight auth succeeded on attempt ${attempt} (previous attempt had auth failure)`
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
return {};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { TaskComment, TeamTask } from '@shared/types';
|
||||
import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamTaskReader');
|
||||
|
||||
|
|
@ -97,6 +97,21 @@ export class TeamTaskReader {
|
|||
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
|
||||
// safety: if a field is added to TeamTask but not mapped here,
|
||||
// TypeScript will error. This prevents silently dropping new fields.
|
||||
const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals)
|
||||
? (parsed.workIntervals as unknown[])
|
||||
.filter(
|
||||
(i): i is { startedAt: string; completedAt?: string } =>
|
||||
Boolean(i) &&
|
||||
typeof i === 'object' &&
|
||||
typeof (i as Record<string, unknown>).startedAt === 'string' &&
|
||||
((i as Record<string, unknown>).completedAt === undefined ||
|
||||
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||
)
|
||||
.map((i) => ({
|
||||
startedAt: i.startedAt,
|
||||
completedAt: i.completedAt,
|
||||
}))
|
||||
: undefined;
|
||||
const task: TeamTask = {
|
||||
id:
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
|
|
@ -110,6 +125,7 @@ export class TeamTaskReader {
|
|||
)
|
||||
? (parsed.status as TeamTask['status'])
|
||||
: 'pending',
|
||||
workIntervals,
|
||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
|
||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
|
||||
related: Array.isArray(parsed.related)
|
||||
|
|
@ -119,15 +135,22 @@ export class TeamTaskReader {
|
|||
updatedAt,
|
||||
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
|
||||
comments: Array.isArray(parsed.comments)
|
||||
? (parsed.comments as TaskComment[]).filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c === 'object' &&
|
||||
typeof c.id === 'string' &&
|
||||
typeof c.author === 'string' &&
|
||||
typeof c.text === 'string' &&
|
||||
typeof c.createdAt === 'string'
|
||||
)
|
||||
? (parsed.comments as TaskComment[])
|
||||
.filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c === 'object' &&
|
||||
typeof c.id === 'string' &&
|
||||
typeof c.author === 'string' &&
|
||||
typeof c.text === 'string' &&
|
||||
typeof c.createdAt === 'string'
|
||||
)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
|
||||
? c.type
|
||||
: ('regular' as const),
|
||||
}))
|
||||
: undefined,
|
||||
needsClarification: (['lead', 'user'] as const).includes(
|
||||
parsed.needsClarification as 'lead' | 'user'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import * as path from 'path';
|
|||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
import type { TaskComment, TeamTask, TeamTaskStatus } from '@shared/types';
|
||||
import type { TaskComment, TaskCommentType, TeamTask, TeamTaskStatus } from '@shared/types';
|
||||
|
||||
const taskWriteLocks = new Map<string, Promise<void>>();
|
||||
|
||||
|
|
@ -46,13 +46,23 @@ export class TeamTaskWriter {
|
|||
|
||||
// Ensure CLI-compatible format: description, blocks, blockedBy are required
|
||||
// by Claude Code CLI's Zod schema validation (safeParse fails without them)
|
||||
const createdAt = task.createdAt ?? new Date().toISOString();
|
||||
const cliCompatibleTask: TeamTask = {
|
||||
...task,
|
||||
description: task.description ?? '',
|
||||
blocks: task.blocks ?? [],
|
||||
blockedBy: task.blockedBy ?? [],
|
||||
related: task.related ?? [],
|
||||
createdAt: task.createdAt ?? new Date().toISOString(),
|
||||
createdAt,
|
||||
workIntervals:
|
||||
task.status === 'in_progress'
|
||||
? // Start the first work interval on creation when task starts immediately.
|
||||
[
|
||||
...(Array.isArray(task.workIntervals) && task.workIntervals.length > 0
|
||||
? task.workIntervals
|
||||
: [{ startedAt: createdAt }]),
|
||||
]
|
||||
: task.workIntervals,
|
||||
};
|
||||
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
|
||||
|
|
@ -92,6 +102,176 @@ export class TeamTaskWriter {
|
|||
});
|
||||
}
|
||||
|
||||
async addRelationship(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
targetId: string,
|
||||
type: 'blockedBy' | 'blocks' | 'related'
|
||||
): Promise<void> {
|
||||
if (taskId === targetId) {
|
||||
throw new Error('Cannot link a task to itself');
|
||||
}
|
||||
|
||||
// For 'blocks', delegate as reverse blockedBy (swap task/target intentionally)
|
||||
if (type === 'blocks') {
|
||||
const swappedTask = targetId;
|
||||
const swappedTarget = taskId;
|
||||
return this.addRelationship(teamName, swappedTask, swappedTarget, 'blockedBy');
|
||||
}
|
||||
|
||||
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||
const taskPath = path.join(tasksDir, `${taskId}.json`);
|
||||
const targetPath = path.join(tasksDir, `${targetId}.json`);
|
||||
|
||||
// Lock both paths in sorted order to avoid deadlocks
|
||||
const [firstPath, secondPath] =
|
||||
taskPath < targetPath ? [taskPath, targetPath] : [targetPath, taskPath];
|
||||
|
||||
await withTaskLock(firstPath, () =>
|
||||
withTaskLock(secondPath, async () => {
|
||||
// Read both tasks
|
||||
const taskRaw = await this.readTaskFile(taskPath, taskId);
|
||||
const targetRaw = await this.readTaskFile(targetPath, targetId);
|
||||
const task = JSON.parse(taskRaw) as TeamTask;
|
||||
const target = JSON.parse(targetRaw) as TeamTask;
|
||||
|
||||
if (type === 'blockedBy') {
|
||||
// Cycle detection: walk target's blockedBy chain to check if taskId is reachable
|
||||
await this.checkBlockCycle(tasksDir, taskId, targetId);
|
||||
|
||||
// task.blockedBy += targetId
|
||||
const blockedBy = task.blockedBy ?? [];
|
||||
if (!blockedBy.includes(targetId)) {
|
||||
task.blockedBy = [...blockedBy, targetId];
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
}
|
||||
// target.blocks += taskId (reverse)
|
||||
const blocks = target.blocks ?? [];
|
||||
if (!blocks.includes(taskId)) {
|
||||
target.blocks = [...blocks, taskId];
|
||||
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
|
||||
}
|
||||
} else {
|
||||
// related — bidirectional
|
||||
const relA = task.related ?? [];
|
||||
if (!relA.includes(targetId)) {
|
||||
task.related = [...relA, targetId];
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
}
|
||||
const relB = target.related ?? [];
|
||||
if (!relB.includes(taskId)) {
|
||||
target.related = [...relB, taskId];
|
||||
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async removeRelationship(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
targetId: string,
|
||||
type: 'blockedBy' | 'blocks' | 'related'
|
||||
): Promise<void> {
|
||||
// For 'blocks', delegate as reverse blockedBy (swap task/target intentionally)
|
||||
if (type === 'blocks') {
|
||||
const swappedTask = targetId;
|
||||
const swappedTarget = taskId;
|
||||
return this.removeRelationship(teamName, swappedTask, swappedTarget, 'blockedBy');
|
||||
}
|
||||
|
||||
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||
const taskPath = path.join(tasksDir, `${taskId}.json`);
|
||||
const targetPath = path.join(tasksDir, `${targetId}.json`);
|
||||
|
||||
const [firstPath, secondPath] =
|
||||
taskPath < targetPath ? [taskPath, targetPath] : [targetPath, taskPath];
|
||||
|
||||
await withTaskLock(firstPath, () =>
|
||||
withTaskLock(secondPath, async () => {
|
||||
// Read task (must exist)
|
||||
const taskRaw = await this.readTaskFile(taskPath, taskId);
|
||||
const task = JSON.parse(taskRaw) as TeamTask;
|
||||
|
||||
if (type === 'blockedBy') {
|
||||
task.blockedBy = (task.blockedBy ?? []).filter((id) => id !== targetId);
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
|
||||
// Remove reverse from target if it exists
|
||||
try {
|
||||
const targetRaw = await fs.promises.readFile(targetPath, 'utf8');
|
||||
const target = JSON.parse(targetRaw) as TeamTask;
|
||||
target.blocks = (target.blocks ?? []).filter((id) => id !== taskId);
|
||||
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
// Target doesn't exist — skip silently
|
||||
}
|
||||
} else {
|
||||
// related — remove bidirectional
|
||||
task.related = (task.related ?? []).filter((id) => id !== targetId);
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
|
||||
try {
|
||||
const targetRaw = await fs.promises.readFile(targetPath, 'utf8');
|
||||
const target = JSON.parse(targetRaw) as TeamTask;
|
||||
target.related = (target.related ?? []).filter((id) => id !== taskId);
|
||||
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async readTaskFile(taskPath: string, taskId: string): Promise<string> {
|
||||
try {
|
||||
return await fs.promises.readFile(taskPath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new Error(`Task not found: ${taskId}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks targetId's blockedBy chain to detect if sourceId is reachable.
|
||||
* Reads are outside locks (deliberate TOCTOU trade-off — the calling method
|
||||
* holds locks on both source and target, and only other tasks are read here).
|
||||
*/
|
||||
private async checkBlockCycle(
|
||||
tasksDir: string,
|
||||
sourceId: string,
|
||||
targetId: string
|
||||
): Promise<void> {
|
||||
const visited = new Set<string>();
|
||||
const stack = [targetId];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
if (current === sourceId) {
|
||||
throw new Error(`Circular dependency: #${targetId} already depends on #${sourceId}`);
|
||||
}
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
|
||||
try {
|
||||
const raw = await fs.promises.readFile(path.join(tasksDir, `${current}.json`), 'utf8');
|
||||
const task = JSON.parse(raw) as TeamTask;
|
||||
if (Array.isArray(task.blockedBy)) {
|
||||
for (const dep of task.blockedBy) {
|
||||
stack.push(dep);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable tasks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
|
||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||
|
||||
|
|
@ -107,6 +287,29 @@ export class TeamTaskWriter {
|
|||
}
|
||||
|
||||
const task = JSON.parse(raw) as TeamTask;
|
||||
const prevStatus = task.status;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
// Maintain workIntervals as periods of time where status === 'in_progress'.
|
||||
const intervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
|
||||
const last = intervals.length > 0 ? intervals[intervals.length - 1] : undefined;
|
||||
|
||||
const wasInProgress = prevStatus === 'in_progress';
|
||||
const isInProgress = status === 'in_progress';
|
||||
|
||||
if (!wasInProgress && isInProgress) {
|
||||
// Entering in_progress: open a new interval if none is open.
|
||||
if (!last || typeof last.completedAt === 'string') {
|
||||
intervals.push({ startedAt: nowIso });
|
||||
}
|
||||
} else if (wasInProgress && !isInProgress) {
|
||||
// Leaving in_progress: close open interval if present.
|
||||
if (last && last.completedAt === undefined) {
|
||||
last.completedAt = nowIso;
|
||||
}
|
||||
}
|
||||
|
||||
task.workIntervals = intervals.length > 0 ? intervals : undefined;
|
||||
task.status = status;
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
|
||||
|
|
@ -157,8 +360,20 @@ export class TeamTaskWriter {
|
|||
}
|
||||
|
||||
const task = JSON.parse(raw) as TeamTask;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
// Ensure any open in_progress interval is closed on delete.
|
||||
if (task.status === 'in_progress') {
|
||||
const intervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
|
||||
const last = intervals.length > 0 ? intervals[intervals.length - 1] : undefined;
|
||||
if (last && last.completedAt === undefined) {
|
||||
last.completedAt = nowIso;
|
||||
}
|
||||
task.workIntervals = intervals.length > 0 ? intervals : task.workIntervals;
|
||||
}
|
||||
|
||||
task.status = 'deleted';
|
||||
task.deletedAt = new Date().toISOString();
|
||||
task.deletedAt = nowIso;
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
|
||||
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
|
||||
|
|
@ -251,7 +466,7 @@ export class TeamTaskWriter {
|
|||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
options?: { id?: string; author?: string; createdAt?: string }
|
||||
options?: { id?: string; author?: string; createdAt?: string; type?: TaskCommentType }
|
||||
): Promise<TaskComment> {
|
||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||
const comment: TaskComment = {
|
||||
|
|
@ -259,6 +474,7 @@ export class TeamTaskWriter {
|
|||
author: options?.author ?? 'user',
|
||||
text,
|
||||
createdAt: options?.createdAt ?? new Date().toISOString(),
|
||||
type: options?.type ?? 'regular',
|
||||
};
|
||||
|
||||
await withTaskLock(taskPath, async () => {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export type WorktreeSource =
|
|||
| 'auto-claude' // /Users/.../.auto-claude/worktrees/tasks/{task-id}
|
||||
| '21st' // /Users/.../.21st/worktrees/{id}/{name [bracket-id]}
|
||||
| 'claude-desktop' // /Users/.../.claude-worktrees/{repo}/{name}
|
||||
| 'claude-code' // /Users/.../.claude/worktrees/{name}
|
||||
| 'ccswitch' // /Users/.../.ccswitch/worktrees/{repo}/{name}
|
||||
| 'git' // Standard git worktree (main repo or detached)
|
||||
| 'unknown'; // Non-git project or undetectable
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
*
|
||||
* Provides security sandboxing for file path access to prevent
|
||||
* unauthorized access to sensitive system files.
|
||||
*
|
||||
* Cross-platform: uses path.resolve() for consistent drive-letter
|
||||
* handling on Windows (normalizeForCompare, isPathWithinRoot).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
|
@ -62,12 +65,14 @@ export interface PathValidationResult {
|
|||
}
|
||||
|
||||
function normalizeForCompare(input: string, isWindows: boolean): string {
|
||||
const normalized = path.normalize(input);
|
||||
const normalized = path.resolve(path.normalize(input));
|
||||
return isWindows ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||
return targetPath === rootPath || targetPath.startsWith(rootPath + path.sep);
|
||||
const target = path.resolve(targetPath);
|
||||
const root = path.resolve(rootPath);
|
||||
return target === root || target.startsWith(root + path.sep);
|
||||
}
|
||||
|
||||
function resolveRealPathIfExists(inputPath: string): string | null {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ export const CONFIG_FIND_WSL_CLAUDE_ROOTS = 'config:findWslClaudeRoots';
|
|||
/** Open config file in external editor */
|
||||
export const CONFIG_OPEN_IN_EDITOR = 'config:openInEditor';
|
||||
|
||||
/** Add a custom project path (Select Folder persistence) */
|
||||
export const CONFIG_ADD_CUSTOM_PROJECT_PATH = 'config:addCustomProjectPath';
|
||||
|
||||
/** Remove a custom project path */
|
||||
export const CONFIG_REMOVE_CUSTOM_PROJECT_PATH = 'config:removeCustomProjectPath';
|
||||
|
||||
/** Pin a session */
|
||||
export const CONFIG_PIN_SESSION = 'config:pinSession';
|
||||
|
||||
|
|
@ -315,6 +321,12 @@ export const TEAM_SET_TASK_CLARIFICATION = 'team:setTaskClarification';
|
|||
/** Show native OS notification for a team message */
|
||||
export const TEAM_SHOW_MESSAGE_NOTIFICATION = 'team:showMessageNotification';
|
||||
|
||||
/** Add a relationship (blockedBy/blocks/related) between two tasks */
|
||||
export const TEAM_ADD_TASK_RELATIONSHIP = 'team:addTaskRelationship';
|
||||
|
||||
/** Remove a relationship (blockedBy/blocks/related) between two tasks */
|
||||
export const TEAM_REMOVE_TASK_RELATIONSHIP = 'team:removeTaskRelationship';
|
||||
|
||||
// =============================================================================
|
||||
// CLI Installer API Channels
|
||||
// =============================================================================
|
||||
|
|
@ -431,6 +443,9 @@ export const EDITOR_DELETE_FILE = 'editor:deleteFile';
|
|||
/** Move file or directory to a new location */
|
||||
export const EDITOR_MOVE_FILE = 'editor:moveFile';
|
||||
|
||||
/** Rename file or directory in place */
|
||||
export const EDITOR_RENAME_FILE = 'editor:renameFile';
|
||||
|
||||
/** Search in files (literal string search) */
|
||||
export const EDITOR_SEARCH_IN_FILES = 'editor:searchInFiles';
|
||||
|
||||
|
|
@ -443,5 +458,17 @@ export const EDITOR_GIT_STATUS = 'editor:gitStatus';
|
|||
/** Enable/disable file watcher for current project */
|
||||
export const EDITOR_WATCH_DIR = 'editor:watchDir';
|
||||
|
||||
/** Update list of watched file paths (open tabs) */
|
||||
export const EDITOR_SET_WATCHED_FILES = 'editor:setWatchedFiles';
|
||||
|
||||
/** Update list of watched directories (shallow: depth=0) */
|
||||
export const EDITOR_SET_WATCHED_DIRS = 'editor:setWatchedDirs';
|
||||
|
||||
/** Read binary file as base64 for inline preview */
|
||||
export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview';
|
||||
|
||||
/** File change event from watcher (main -> renderer) */
|
||||
export const EDITOR_CHANGE = 'editor:change';
|
||||
|
||||
/** List project files by path (for @file mentions, independent of editor state) */
|
||||
export const PROJECT_LIST_FILES = 'project:listFiles';
|
||||
|
|
|
|||
|
|
@ -19,14 +19,19 @@ import {
|
|||
EDITOR_LIST_FILES,
|
||||
EDITOR_MOVE_FILE,
|
||||
EDITOR_OPEN,
|
||||
EDITOR_READ_BINARY_PREVIEW,
|
||||
EDITOR_READ_DIR,
|
||||
EDITOR_READ_FILE,
|
||||
EDITOR_RENAME_FILE,
|
||||
EDITOR_SEARCH_IN_FILES,
|
||||
EDITOR_SET_WATCHED_DIRS,
|
||||
EDITOR_SET_WATCHED_FILES,
|
||||
EDITOR_WATCH_DIR,
|
||||
EDITOR_WRITE_FILE,
|
||||
HTTP_SERVER_GET_STATUS,
|
||||
HTTP_SERVER_START,
|
||||
HTTP_SERVER_STOP,
|
||||
PROJECT_LIST_FILES,
|
||||
REVIEW_APPLY_DECISIONS,
|
||||
REVIEW_CHECK_CONFLICT,
|
||||
REVIEW_CLEAR_DECISIONS,
|
||||
|
|
@ -52,6 +57,7 @@ import {
|
|||
SSH_TEST,
|
||||
TEAM_ADD_MEMBER,
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
TEAM_ADD_TASK_RELATIONSHIP,
|
||||
TEAM_ALIVE_LIST,
|
||||
TEAM_CANCEL_PROVISIONING,
|
||||
TEAM_CHANGE,
|
||||
|
|
@ -78,6 +84,7 @@ import {
|
|||
TEAM_PROVISIONING_PROGRESS,
|
||||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
|
|
@ -112,6 +119,7 @@ import {
|
|||
WINDOW_MINIMIZE,
|
||||
} from './constants/ipcChannels';
|
||||
import {
|
||||
CONFIG_ADD_CUSTOM_PROJECT_PATH,
|
||||
CONFIG_ADD_IGNORE_REGEX,
|
||||
CONFIG_ADD_IGNORE_REPOSITORY,
|
||||
CONFIG_ADD_TRIGGER,
|
||||
|
|
@ -124,6 +132,7 @@ import {
|
|||
CONFIG_HIDE_SESSIONS,
|
||||
CONFIG_OPEN_IN_EDITOR,
|
||||
CONFIG_PIN_SESSION,
|
||||
CONFIG_REMOVE_CUSTOM_PROJECT_PATH,
|
||||
CONFIG_REMOVE_IGNORE_REGEX,
|
||||
CONFIG_REMOVE_IGNORE_REPOSITORY,
|
||||
CONFIG_REMOVE_TRIGGER,
|
||||
|
|
@ -195,6 +204,7 @@ import type {
|
|||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
import type {
|
||||
BinaryPreviewResult,
|
||||
CreateDirResponse,
|
||||
CreateFileResponse,
|
||||
DeleteFileResponse,
|
||||
|
|
@ -441,6 +451,12 @@ const electronAPI: ElectronAPI = {
|
|||
unhideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSIONS, projectId, sessionIds);
|
||||
},
|
||||
addCustomProjectPath: async (projectPath: string): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(CONFIG_ADD_CUSTOM_PROJECT_PATH, projectPath);
|
||||
},
|
||||
removeCustomProjectPath: async (projectPath: string): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(CONFIG_REMOVE_CUSTOM_PROJECT_PATH, projectPath);
|
||||
},
|
||||
},
|
||||
|
||||
// Deep link navigation
|
||||
|
|
@ -696,7 +712,12 @@ const electronAPI: ElectronAPI = {
|
|||
getLogsForTask: async (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: { owner?: string; status?: string }
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
}
|
||||
) => {
|
||||
return invokeIpcWithResult<MemberLogSummary[]>(
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
|
|
@ -758,6 +779,34 @@ const electronAPI: ElectronAPI = {
|
|||
showMessageNotification: async (data: TeamMessageNotificationData) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SHOW_MESSAGE_NOTIFICATION, data);
|
||||
},
|
||||
addTaskRelationship: async (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
targetId: string,
|
||||
type: 'blockedBy' | 'blocks' | 'related'
|
||||
) => {
|
||||
return invokeIpcWithResult<void>(
|
||||
TEAM_ADD_TASK_RELATIONSHIP,
|
||||
teamName,
|
||||
taskId,
|
||||
targetId,
|
||||
type
|
||||
);
|
||||
},
|
||||
removeTaskRelationship: async (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
targetId: string,
|
||||
type: 'blockedBy' | 'blocks' | 'related'
|
||||
) => {
|
||||
return invokeIpcWithResult<void>(
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
teamName,
|
||||
taskId,
|
||||
targetId,
|
||||
type
|
||||
);
|
||||
},
|
||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||
ipcRenderer.on(
|
||||
TEAM_CHANGE,
|
||||
|
|
@ -858,28 +907,36 @@ const electronAPI: ElectronAPI = {
|
|||
);
|
||||
},
|
||||
// Editable diff
|
||||
saveEditedFile: async (filePath: string, content: string) => {
|
||||
return invokeIpcWithResult<{ success: boolean }>(REVIEW_SAVE_EDITED_FILE, filePath, content);
|
||||
saveEditedFile: async (filePath: string, content: string, projectPath?: string) => {
|
||||
return invokeIpcWithResult<{ success: boolean }>(
|
||||
REVIEW_SAVE_EDITED_FILE,
|
||||
filePath,
|
||||
content,
|
||||
projectPath
|
||||
);
|
||||
},
|
||||
// Decision persistence
|
||||
loadDecisions: async (teamName: string, scopeKey: string) => {
|
||||
return invokeIpcWithResult<{
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>;
|
||||
} | null>(REVIEW_LOAD_DECISIONS, teamName, scopeKey);
|
||||
},
|
||||
saveDecisions: async (
|
||||
teamName: string,
|
||||
scopeKey: string,
|
||||
hunkDecisions: Record<string, HunkDecision>,
|
||||
fileDecisions: Record<string, HunkDecision>
|
||||
fileDecisions: Record<string, HunkDecision>,
|
||||
hunkContextHashesByFile?: Record<string, Record<number, string>>
|
||||
) => {
|
||||
return invokeIpcWithResult<void>(
|
||||
REVIEW_SAVE_DECISIONS,
|
||||
teamName,
|
||||
scopeKey,
|
||||
hunkDecisions,
|
||||
fileDecisions
|
||||
fileDecisions,
|
||||
hunkContextHashesByFile ?? null
|
||||
);
|
||||
},
|
||||
clearDecisions: async (teamName: string, scopeKey: string) => {
|
||||
|
|
@ -957,6 +1014,12 @@ const electronAPI: ElectronAPI = {
|
|||
},
|
||||
},
|
||||
|
||||
// ===== Project API (editor-independent) =====
|
||||
project: {
|
||||
listFiles: (projectPath: string) =>
|
||||
invokeIpcWithResult<QuickOpenFile[]>(PROJECT_LIST_FILES, projectPath),
|
||||
},
|
||||
|
||||
// ===== Editor API =====
|
||||
editor: {
|
||||
open: (projectPath: string) => invokeIpcWithResult<void>(EDITOR_OPEN, projectPath),
|
||||
|
|
@ -974,11 +1037,19 @@ const electronAPI: ElectronAPI = {
|
|||
invokeIpcWithResult<DeleteFileResponse>(EDITOR_DELETE_FILE, filePath),
|
||||
moveFile: (sourcePath: string, destDir: string) =>
|
||||
invokeIpcWithResult<MoveFileResponse>(EDITOR_MOVE_FILE, sourcePath, destDir),
|
||||
renameFile: (sourcePath: string, newName: string) =>
|
||||
invokeIpcWithResult<MoveFileResponse>(EDITOR_RENAME_FILE, sourcePath, newName),
|
||||
searchInFiles: (options: SearchInFilesOptions) =>
|
||||
invokeIpcWithResult<SearchInFilesResult>(EDITOR_SEARCH_IN_FILES, options),
|
||||
listFiles: () => invokeIpcWithResult<QuickOpenFile[]>(EDITOR_LIST_FILES),
|
||||
readBinaryPreview: (filePath: string) =>
|
||||
invokeIpcWithResult<BinaryPreviewResult>(EDITOR_READ_BINARY_PREVIEW, filePath),
|
||||
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
|
||||
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
|
||||
setWatchedFiles: (filePaths: string[]) =>
|
||||
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_FILES, filePaths),
|
||||
setWatchedDirs: (dirPaths: string[]) =>
|
||||
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_DIRS, dirPaths),
|
||||
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
|
||||
callback(data);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ import type {
|
|||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
import type { EditorAPI } from '@shared/types/editor';
|
||||
import type { EditorAPI, ProjectAPI } from '@shared/types/editor';
|
||||
import type { TerminalAPI } from '@shared/types/terminal';
|
||||
|
||||
export class HttpAPIClient implements ElectronAPI {
|
||||
|
|
@ -481,6 +481,10 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
this.post('/api/config/hide-sessions', { projectId, sessionIds }),
|
||||
unhideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
|
||||
this.post('/api/config/unhide-sessions', { projectId, sessionIds }),
|
||||
addCustomProjectPath: (projectPath: string): Promise<void> =>
|
||||
this.post('/api/config/add-custom-project-path', { projectPath }),
|
||||
removeCustomProjectPath: (projectPath: string): Promise<void> =>
|
||||
this.post('/api/config/remove-custom-project-path', { projectPath }),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -808,6 +812,22 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
showMessageNotification: async (): Promise<void> => {
|
||||
// Not available via HTTP client — native notifications require Electron
|
||||
},
|
||||
addTaskRelationship: async (
|
||||
_teamName: string,
|
||||
_taskId: string,
|
||||
_targetId: string,
|
||||
_type: 'blockedBy' | 'blocks' | 'related'
|
||||
): Promise<void> => {
|
||||
throw new Error('Task relationships are not available in browser mode');
|
||||
},
|
||||
removeTaskRelationship: async (
|
||||
_teamName: string,
|
||||
_taskId: string,
|
||||
_targetId: string,
|
||||
_type: 'blockedBy' | 'blocks' | 'related'
|
||||
): Promise<void> => {
|
||||
throw new Error('Task relationships are not available in browser mode');
|
||||
},
|
||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||
return this.addEventListener('team-change', (data: unknown) =>
|
||||
callback(null, data as TeamChangeEvent)
|
||||
|
|
@ -863,7 +883,13 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
loadDecisions: async (): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
saveDecisions: async (): Promise<never> => {
|
||||
saveDecisions: async (
|
||||
_teamName: string,
|
||||
_scopeKey: string,
|
||||
_hunkDecisions: Record<string, unknown>,
|
||||
_fileDecisions: Record<string, unknown>,
|
||||
_hunkContextHashesByFile?: Record<string, Record<number, string>>
|
||||
): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
clearDecisions: async (): Promise<never> => {
|
||||
|
|
@ -912,6 +938,16 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
onExit: (): (() => void) => () => {},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project (not available in browser mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
project: ProjectAPI = {
|
||||
listFiles: async () => {
|
||||
throw new Error('Project API not available in browser mode');
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor (not available in browser mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -944,18 +980,30 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
moveFile: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
renameFile: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
searchInFiles: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
listFiles: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
readBinaryPreview: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
gitStatus: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
watchDir: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
setWatchedFiles: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
setWatchedDirs: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
onEditorChange: () => {
|
||||
return () => {};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -224,6 +224,46 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
return map;
|
||||
}, [conversation]);
|
||||
|
||||
// --- New-item animation tracking ---
|
||||
const knownGroupIdsRef = useRef<Set<string>>(new Set());
|
||||
const isInitialRenderRef = useRef(true);
|
||||
const prevTabIdRef = useRef(effectiveTabId);
|
||||
|
||||
// Reset animation tracking when switching tabs/sessions
|
||||
if (prevTabIdRef.current !== effectiveTabId) {
|
||||
prevTabIdRef.current = effectiveTabId;
|
||||
knownGroupIdsRef.current.clear();
|
||||
isInitialRenderRef.current = true;
|
||||
}
|
||||
|
||||
const newGroupIds = useMemo(() => {
|
||||
const items = conversation?.items;
|
||||
if (!items || items.length === 0) {
|
||||
knownGroupIdsRef.current.clear();
|
||||
isInitialRenderRef.current = true;
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// First render: seed all known IDs, no animations
|
||||
if (isInitialRenderRef.current) {
|
||||
isInitialRenderRef.current = false;
|
||||
for (const item of items) {
|
||||
knownGroupIdsRef.current.add(item.group.id);
|
||||
}
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// Subsequent updates: detect new items
|
||||
const newIds = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (!knownGroupIdsRef.current.has(item.group.id)) {
|
||||
newIds.add(item.group.id);
|
||||
knownGroupIdsRef.current.add(item.group.id);
|
||||
}
|
||||
}
|
||||
return newIds;
|
||||
}, [conversation]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? (conversation?.items.length ?? 0) : 0,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
|
|
@ -849,6 +889,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
isSearchHighlight={isSearchHighlight}
|
||||
isNavigationHighlight={isNavigationHighlight}
|
||||
highlightColor={effectiveHighlightColor}
|
||||
isNew={newGroupIds.has(item.group.id)}
|
||||
registerChatItemRef={registerChatItemRef}
|
||||
registerAIGroupRef={registerAIGroupRefCombined}
|
||||
registerToolRef={registerToolRef}
|
||||
|
|
@ -867,6 +908,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
isSearchHighlight={isSearchHighlight}
|
||||
isNavigationHighlight={isNavigationHighlight}
|
||||
highlightColor={effectiveHighlightColor}
|
||||
isNew={newGroupIds.has(item.group.id)}
|
||||
registerChatItemRef={registerChatItemRef}
|
||||
registerAIGroupRef={registerAIGroupRefCombined}
|
||||
registerToolRef={registerToolRef}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ interface ChatHistoryItemProps {
|
|||
readonly isSearchHighlight: boolean;
|
||||
readonly isNavigationHighlight: boolean;
|
||||
readonly highlightColor?: TriggerColor;
|
||||
/** Whether this item just appeared (triggers enter animation) */
|
||||
readonly isNew?: boolean;
|
||||
readonly registerChatItemRef: (groupId: string) => (el: HTMLElement | null) => void;
|
||||
readonly registerAIGroupRef: (groupId: string) => (el: HTMLElement | null) => void;
|
||||
/** Register ref for individual tool items (for precise scroll targeting) */
|
||||
|
|
@ -54,10 +56,13 @@ const ChatHistoryItemInner = ({
|
|||
isSearchHighlight,
|
||||
isNavigationHighlight,
|
||||
highlightColor,
|
||||
isNew,
|
||||
registerChatItemRef,
|
||||
registerAIGroupRef,
|
||||
registerToolRef,
|
||||
}: ChatHistoryItemProps): JSX.Element | null => {
|
||||
const enterClass = isNew ? 'chat-message-enter-animate' : '';
|
||||
|
||||
switch (item.type) {
|
||||
case 'user': {
|
||||
const isHighlighted = highlightedGroupId === item.group.id;
|
||||
|
|
@ -70,7 +75,7 @@ const ChatHistoryItemInner = ({
|
|||
return (
|
||||
<div
|
||||
ref={registerChatItemRef(item.group.id)}
|
||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className}`}
|
||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
style={hl.style}
|
||||
>
|
||||
<UserChatGroup userGroup={item.group} />
|
||||
|
|
@ -88,7 +93,7 @@ const ChatHistoryItemInner = ({
|
|||
return (
|
||||
<div
|
||||
ref={registerChatItemRef(item.group.id)}
|
||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className}`}
|
||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
style={hl.style}
|
||||
>
|
||||
<SystemChatGroup systemGroup={item.group} />
|
||||
|
|
@ -110,7 +115,7 @@ const ChatHistoryItemInner = ({
|
|||
return (
|
||||
<div
|
||||
ref={registerAIGroupRef(item.group.id)}
|
||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className}`}
|
||||
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
|
||||
style={hl.style}
|
||||
>
|
||||
<AIChatGroup
|
||||
|
|
@ -123,7 +128,13 @@ const ChatHistoryItemInner = ({
|
|||
);
|
||||
}
|
||||
case 'compact':
|
||||
return <CompactBoundary compactGroup={item.group} />;
|
||||
return isNew ? (
|
||||
<div className={enterClass}>
|
||||
<CompactBoundary compactGroup={item.group} />
|
||||
</div>
|
||||
) : (
|
||||
<CompactBoundary compactGroup={item.group} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
PROSE_TABLE_HEADER_BG,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||
import { FileText } from 'lucide-react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -34,6 +34,8 @@ import {
|
|||
type SearchContext,
|
||||
} from '../searchHighlightUtils';
|
||||
|
||||
import { MermaidDiagram } from './MermaidDiagram';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
|
@ -49,6 +51,100 @@ interface MarkdownViewerProps {
|
|||
copyable?: boolean;
|
||||
/** When true, renders without wrapper background/border (for embedding inside cards) */
|
||||
bare?: boolean;
|
||||
/** Base directory for resolving relative URLs (images, links) via local-resource:// protocol */
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/** Check if a URL is relative (not absolute, not data, not mailto, not hash) */
|
||||
function isRelativeUrl(url: string): boolean {
|
||||
return (
|
||||
!!url &&
|
||||
!url.startsWith('http://') &&
|
||||
!url.startsWith('https://') &&
|
||||
!url.startsWith('data:') &&
|
||||
!url.startsWith('#') &&
|
||||
!url.startsWith('mailto:')
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a relative path to an absolute path given a base directory */
|
||||
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
|
||||
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
|
||||
return `${baseDir}/${cleaned}`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LocalImage — loads images via IPC (readBinaryPreview) for local file access
|
||||
// =============================================================================
|
||||
|
||||
interface LocalImageProps {
|
||||
src: string;
|
||||
alt?: string;
|
||||
baseDir: string;
|
||||
}
|
||||
|
||||
const LocalImage = React.memo(function LocalImage({
|
||||
src,
|
||||
alt,
|
||||
baseDir,
|
||||
}: LocalImageProps): React.ReactElement {
|
||||
const [dataUrl, setDataUrl] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDataUrl(null);
|
||||
setError(false);
|
||||
|
||||
const fullPath = resolveRelativePath(src, baseDir);
|
||||
window.electronAPI.editor
|
||||
.readBinaryPreview(fullPath)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setDataUrl(`data:${result.mimeType};base64,${result.base64}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src, baseDir]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-text-muted">
|
||||
[Image: {alt || src}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataUrl) {
|
||||
return (
|
||||
<span className="inline-block size-4 animate-pulse rounded bg-surface-raised align-middle" />
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={dataUrl} alt={alt || ''} className="my-2 max-w-full rounded" />;
|
||||
});
|
||||
|
||||
/** Extract plain text from a hast (HTML AST) node tree */
|
||||
interface HastNode {
|
||||
type: string;
|
||||
value?: string;
|
||||
children?: HastNode[];
|
||||
}
|
||||
|
||||
function hastToText(node: HastNode): string {
|
||||
if (node.type === 'text') return node.value ?? '';
|
||||
if (node.children) return node.children.map(hastToText).join('');
|
||||
return '';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -180,18 +276,29 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
|||
);
|
||||
},
|
||||
|
||||
// Code blocks
|
||||
pre: ({ children }) => (
|
||||
<pre
|
||||
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
|
||||
style={{
|
||||
backgroundColor: PROSE_PRE_BG,
|
||||
border: `1px solid ${PROSE_PRE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
// Code blocks — intercept mermaid diagrams at the pre level
|
||||
pre: ({ children, node }) => {
|
||||
// Check if this pre contains a mermaid code block
|
||||
const codeEl = node?.children?.[0];
|
||||
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
|
||||
const cls = (codeEl.properties as Record<string, unknown>)?.className;
|
||||
if (Array.isArray(cls) && cls.some((c) => String(c) === 'language-mermaid')) {
|
||||
return <MermaidDiagram code={hastToText(codeEl as unknown as HastNode)} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
|
||||
style={{
|
||||
backgroundColor: PROSE_PRE_BG,
|
||||
border: `1px solid ${PROSE_PRE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
|
|
@ -288,6 +395,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
itemId,
|
||||
copyable = false,
|
||||
bare = false,
|
||||
baseDir,
|
||||
}) => {
|
||||
const [showRaw, setShowRaw] = React.useState(false);
|
||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||
|
|
@ -435,7 +543,20 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
// Create markdown components with optional search highlighting
|
||||
// When search is active, create fresh each render (match counter is stateful and must start at 0)
|
||||
// useMemo would cache stale closures when parent re-renders without search deps changing
|
||||
const components = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents;
|
||||
const baseComponents = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents;
|
||||
|
||||
// When baseDir is set (editor preview), override img to load local files via IPC
|
||||
const components = baseDir
|
||||
? {
|
||||
...baseComponents,
|
||||
img: ({ src, alt }: { src?: string; alt?: string }) => {
|
||||
if (src && isRelativeUrl(src)) {
|
||||
return <LocalImage src={src} alt={alt} baseDir={baseDir} />;
|
||||
}
|
||||
return <img src={src} alt={alt || ''} className="my-2 max-w-full rounded" />;
|
||||
},
|
||||
}
|
||||
: baseComponents;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -481,8 +602,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
<div className="min-w-0 break-words p-4">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={disableHighlight ? [] : REHYPE_PLUGINS}
|
||||
urlTransform={(url) => url}
|
||||
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
|
|
|
|||
116
src/renderer/components/chat/viewers/MermaidDiagram.tsx
Normal file
116
src/renderer/components/chat/viewers/MermaidDiagram.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Renders a Mermaid diagram from code string to SVG.
|
||||
*
|
||||
* Lazy-initializes mermaid once with dark theme.
|
||||
* Each render call uses a unique ID to avoid collisions.
|
||||
* SVG output is sanitized with DOMPurify before DOM insertion.
|
||||
* Falls back to raw code display on parse errors.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { PROSE_PRE_BG, PROSE_PRE_BORDER } from '@renderer/constants/cssVariables';
|
||||
import DOMPurify from 'dompurify';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
// =============================================================================
|
||||
// Mermaid initialization (once per app lifecycle)
|
||||
// =============================================================================
|
||||
|
||||
let initialized = false;
|
||||
|
||||
function ensureMermaidInit(): void {
|
||||
if (initialized) return;
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'dark',
|
||||
themeVariables: {
|
||||
darkMode: true,
|
||||
background: 'transparent',
|
||||
primaryColor: '#3b82f6',
|
||||
primaryTextColor: '#fafafa',
|
||||
primaryBorderColor: '#3b82f6',
|
||||
lineColor: '#71717a',
|
||||
secondaryColor: '#27272a',
|
||||
tertiaryColor: '#1f1f23',
|
||||
},
|
||||
});
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
// Monotonic counter for unique diagram IDs
|
||||
let idCounter = 0;
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
interface MermaidDiagramProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export const MermaidDiagram = React.memo(function MermaidDiagram({
|
||||
code,
|
||||
}: MermaidDiagramProps): React.ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code.trim()) return;
|
||||
|
||||
ensureMermaidInit();
|
||||
|
||||
let cancelled = false;
|
||||
const diagramId = `mermaid-${++idCounter}`;
|
||||
|
||||
mermaid
|
||||
.render(diagramId, code.trim())
|
||||
.then(({ svg }) => {
|
||||
if (!cancelled && containerRef.current) {
|
||||
const sanitized = DOMPurify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['foreignObject'],
|
||||
});
|
||||
containerRef.current.replaceChildren();
|
||||
containerRef.current.insertAdjacentHTML('afterbegin', sanitized);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="my-3 overflow-auto rounded-lg p-3 text-xs"
|
||||
style={{
|
||||
backgroundColor: PROSE_PRE_BG,
|
||||
border: `1px solid ${PROSE_PRE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 text-amber-400">Mermaid syntax error</div>
|
||||
<pre className="whitespace-pre-wrap font-mono text-text-muted">{code}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="my-3 flex justify-center overflow-auto rounded-lg p-3"
|
||||
style={{
|
||||
backgroundColor: PROSE_PRE_BG,
|
||||
border: `1px solid ${PROSE_PRE_BORDER}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -52,6 +52,11 @@ const SOURCE_CONFIG: Record<WorktreeSource, SourceConfig> = {
|
|||
bgColor: WORKTREE_BADGE_BG,
|
||||
textColor: WORKTREE_BADGE_TEXT,
|
||||
},
|
||||
'claude-code': {
|
||||
label: 'Worktree',
|
||||
bgColor: WORKTREE_BADGE_BG,
|
||||
textColor: WORKTREE_BADGE_TEXT,
|
||||
},
|
||||
ccswitch: {
|
||||
label: 'ccswitch',
|
||||
bgColor: WORKTREE_BADGE_BG,
|
||||
|
|
|
|||
|
|
@ -24,8 +24,9 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
const logger = createLogger('Component:DashboardView');
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Users } from 'lucide-react';
|
||||
import { Command, FolderGit2, FolderOpen, GitBranch, GitFork, Search, Users } from 'lucide-react';
|
||||
|
||||
import { CliStatusBanner } from './CliStatusBanner';
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
|
|||
// Handle Cmd+K to open full command palette
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyK') {
|
||||
e.preventDefault();
|
||||
openCommandPalette();
|
||||
}
|
||||
|
|
@ -138,6 +139,41 @@ const RepositoryCard = ({
|
|||
const mainWorktree = repo.worktrees.find((w) => w.isMainWorktree) ?? repo.worktrees[0];
|
||||
const mainBranch = mainWorktree?.gitBranch;
|
||||
|
||||
// Detect if this is a worktree project:
|
||||
// 1. No main worktree in the group (isMainWorktree flag)
|
||||
// 2. OR the shown worktree has a tool-created source
|
||||
// 3. OR path-based fallback for .claude/worktrees/ directories
|
||||
const WORKTREE_PATH_MARKERS = [
|
||||
'/.claude/worktrees/',
|
||||
'/.claude-worktrees/',
|
||||
'/.auto-claude/worktrees/',
|
||||
'/.21st/worktrees/',
|
||||
'/.ccswitch/worktrees/',
|
||||
'/.cursor/worktrees/',
|
||||
'/vibe-kanban/worktrees/',
|
||||
'/conductor/workspaces/',
|
||||
];
|
||||
|
||||
const shownWorktree = repo.worktrees[0];
|
||||
const isWorktreeBySource =
|
||||
shownWorktree?.source && !['git', 'unknown'].includes(shownWorktree.source);
|
||||
const isWorktreeByPath =
|
||||
shownWorktree && WORKTREE_PATH_MARKERS.some((m) => shownWorktree.path.includes(m));
|
||||
const isWorktreeProject =
|
||||
!repo.worktrees.some((w) => w.isMainWorktree) || isWorktreeBySource || isWorktreeByPath;
|
||||
|
||||
// Get the source label for worktree badge
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
'vibe-kanban': 'Vibe',
|
||||
conductor: 'Conductor',
|
||||
'auto-claude': 'Auto',
|
||||
'21st': '21st',
|
||||
'claude-desktop': 'Desktop',
|
||||
'claude-code': 'Worktree',
|
||||
ccswitch: 'ccswitch',
|
||||
};
|
||||
const worktreeSourceLabel = shownWorktree?.source && SOURCE_LABELS[shownWorktree.source];
|
||||
|
||||
const color = useMemo(() => projectColor(repo.name), [repo.name]);
|
||||
const cardRef = useRef<HTMLButtonElement>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
|
@ -171,30 +207,49 @@ const RepositoryCard = ({
|
|||
{/* Icon + Project name */}
|
||||
<div className="mb-1 flex items-center gap-2.5">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
|
||||
<FolderGit2
|
||||
className="size-4 transition-colors group-hover:text-text"
|
||||
style={{ color: color.icon }}
|
||||
/>
|
||||
{isWorktreeProject ? (
|
||||
<GitFork
|
||||
className="size-4 transition-colors group-hover:text-text"
|
||||
style={{ color: color.icon }}
|
||||
/>
|
||||
) : (
|
||||
<FolderGit2
|
||||
className="size-4 transition-colors group-hover:text-text"
|
||||
style={{ color: color.icon }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
||||
{repo.name}
|
||||
</h3>
|
||||
{isWorktreeProject && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-purple-500/15 px-1.5 py-0.5 text-[9px] font-medium text-purple-400">
|
||||
{worktreeSourceLabel ?? 'Worktree'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project path - monospace, muted, clickable to open in file manager */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleOpenPath}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') handleOpenPath(e as unknown as React.MouseEvent);
|
||||
}}
|
||||
className="flex w-full min-w-0 cursor-pointer items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary"
|
||||
title={`Open in file manager: ${projectPath}`}
|
||||
>
|
||||
<FolderOpen className="size-3 shrink-0" />
|
||||
<span className="truncate">{formattedPath}</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleOpenPath}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
handleOpenPath(e as unknown as React.MouseEvent);
|
||||
}}
|
||||
className="flex w-full min-w-0 cursor-pointer items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary"
|
||||
>
|
||||
<FolderOpen className="size-3 shrink-0" />
|
||||
<span className="truncate">{formattedPath}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<p className="font-mono text-[11px]">{projectPath}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Git branch / worktree info */}
|
||||
{mainBranch ? (
|
||||
|
|
@ -316,10 +371,11 @@ function findMatchingWorktree(
|
|||
}
|
||||
|
||||
const NewProjectCard = (): React.JSX.Element => {
|
||||
const { repositoryGroups, fetchRepositoryGroups } = useStore(
|
||||
const { repositoryGroups, fetchRepositoryGroups, openTeamsTab } = useStore(
|
||||
useShallow((s) => ({
|
||||
repositoryGroups: s.repositoryGroups,
|
||||
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
||||
openTeamsTab: s.openTeamsTab,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -341,6 +397,7 @@ const NewProjectCard = (): React.JSX.Element => {
|
|||
const match = findMatchingWorktree(repositoryGroups, selectedPath);
|
||||
if (match) {
|
||||
navigateToMatch(match);
|
||||
openTeamsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -350,11 +407,15 @@ const NewProjectCard = (): React.JSX.Element => {
|
|||
const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath);
|
||||
if (matchAfterRefresh) {
|
||||
navigateToMatch(matchAfterRefresh);
|
||||
openTeamsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
// Still no match — create a synthetic group for this new folder and navigate to it.
|
||||
// This allows launching teams in projects that don't have Claude sessions yet.
|
||||
// Persist the path so it survives app restarts.
|
||||
await api.config.addCustomProjectPath(selectedPath);
|
||||
|
||||
const encodedId = selectedPath.replace(/[/\\]/g, '-');
|
||||
const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath;
|
||||
const now = Date.now();
|
||||
|
|
@ -382,6 +443,7 @@ const NewProjectCard = (): React.JSX.Element => {
|
|||
repositoryGroups: [syntheticGroup, ...state.repositoryGroups],
|
||||
}));
|
||||
navigateToMatch({ repoId: encodedId, worktreeId: encodedId });
|
||||
openTeamsTab();
|
||||
} catch (error) {
|
||||
logger.error('Error selecting folder:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
|||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'g' && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.code === 'KeyG' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setGlobalSearchEnabled((prev) => !prev);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ export interface SafeConfig {
|
|||
snoozedUntil: number | null;
|
||||
snoozeMinutes: number;
|
||||
includeSubagentErrors: boolean;
|
||||
notifyOnLeadInbox: boolean;
|
||||
notifyOnUserInbox: boolean;
|
||||
notifyOnClarifications: boolean;
|
||||
triggers: AppConfig['notifications']['triggers'];
|
||||
};
|
||||
display: {
|
||||
|
|
@ -169,6 +172,9 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
|
||||
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
|
||||
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true,
|
||||
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
|
||||
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
|
||||
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
||||
triggers: displayConfig?.notifications?.triggers ?? [],
|
||||
},
|
||||
display: {
|
||||
|
|
|
|||
|
|
@ -287,6 +287,9 @@ export function useSettingsHandlers({
|
|||
snoozedUntil: null,
|
||||
snoozeMinutes: 30,
|
||||
includeSubagentErrors: true,
|
||||
notifyOnLeadInbox: false,
|
||||
notifyOnUserInbox: true,
|
||||
notifyOnClarifications: true,
|
||||
triggers: defaultTriggers,
|
||||
},
|
||||
general: {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,13 @@ interface NotificationsSectionProps {
|
|||
readonly ignoredRepositoryItems: RepositoryDropdownItem[];
|
||||
readonly excludedRepositoryIds: string[];
|
||||
readonly onNotificationToggle: (
|
||||
key: 'enabled' | 'soundEnabled' | 'includeSubagentErrors',
|
||||
key:
|
||||
| 'enabled'
|
||||
| 'soundEnabled'
|
||||
| 'includeSubagentErrors'
|
||||
| 'notifyOnLeadInbox'
|
||||
| 'notifyOnUserInbox'
|
||||
| 'notifyOnClarifications',
|
||||
value: boolean
|
||||
) => void;
|
||||
readonly onSnooze: (minutes: number) => Promise<void>;
|
||||
|
|
@ -130,6 +136,36 @@ export const NotificationsSection = ({
|
|||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Lead inbox notifications"
|
||||
description="Notify when teammates send messages to the team lead"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnLeadInbox}
|
||||
onChange={(v) => onNotificationToggle('notifyOnLeadInbox', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="User inbox notifications"
|
||||
description="Notify when teammates send messages to you"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnUserInbox}
|
||||
onChange={(v) => onNotificationToggle('notifyOnUserInbox', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Task clarification notifications"
|
||||
description="Show native OS notifications when a task needs your input"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnClarifications}
|
||||
onChange={(v) => onNotificationToggle('notifyOnClarifications', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Snooze notifications"
|
||||
description={
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ const SOURCE_LABELS: Record<WorktreeSource, string> = {
|
|||
'auto-claude': 'Auto Claude',
|
||||
'21st': '21st',
|
||||
'claude-desktop': 'Claude Desktop',
|
||||
'claude-code': 'Claude Code',
|
||||
ccswitch: 'ccswitch',
|
||||
git: 'Git',
|
||||
unknown: 'Other',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ interface MemberBadgeProps {
|
|||
color?: string;
|
||||
/** Avatar + badge size variant */
|
||||
size?: 'sm' | 'md';
|
||||
/** Hide the avatar icon, show only the name badge */
|
||||
hideAvatar?: boolean;
|
||||
onClick?: (name: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -18,6 +20,7 @@ export const MemberBadge = ({
|
|||
name,
|
||||
color,
|
||||
size = 'sm',
|
||||
hideAvatar,
|
||||
onClick,
|
||||
}: MemberBadgeProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(color ?? '');
|
||||
|
|
@ -59,7 +62,7 @@ export const MemberBadge = ({
|
|||
onClick(name);
|
||||
}}
|
||||
>
|
||||
{avatar}
|
||||
{!hideAvatar && avatar}
|
||||
{badge}
|
||||
</button>
|
||||
);
|
||||
|
|
@ -67,7 +70,7 @@ export const MemberBadge = ({
|
|||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{avatar}
|
||||
{!hideAvatar && avatar}
|
||||
{badge}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
|
|
@ -26,6 +27,7 @@ import {
|
|||
AlertTriangle,
|
||||
Bell,
|
||||
CheckCheck,
|
||||
Code,
|
||||
Columns3,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
|
|
@ -74,6 +76,7 @@ import { TeamSessionsSection } from './TeamSessionsSection';
|
|||
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
||||
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
|
||||
import type { Session } from '@renderer/types/data';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||
|
||||
|
|
@ -89,6 +92,7 @@ interface CreateTaskDialogState {
|
|||
defaultDescription: string;
|
||||
defaultOwner: string;
|
||||
defaultStartImmediately?: boolean;
|
||||
defaultChip?: InlineChip;
|
||||
}
|
||||
|
||||
interface TimeWindow {
|
||||
|
|
@ -148,6 +152,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
|
||||
const [sendDialogDefaultText, setSendDialogDefaultText] = useState<string | undefined>(undefined);
|
||||
const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState<InlineChip | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
|
@ -259,6 +266,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
});
|
||||
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
|
||||
|
||||
// Open editor overlay when a file reveal is requested (e.g. from chip click)
|
||||
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
|
||||
useEffect(() => {
|
||||
if (pendingRevealFile && data?.config.projectPath) {
|
||||
setEditorOpen(true);
|
||||
}
|
||||
}, [pendingRevealFile, data?.config.projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamName) {
|
||||
return;
|
||||
|
|
@ -526,13 +541,26 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
|
||||
const handleEditorAction = useCallback(
|
||||
(action: EditorSelectionAction) => {
|
||||
const chip = createChipFromSelection(action, []) ?? undefined;
|
||||
if (action.type === 'sendMessage') {
|
||||
setSendDialogDefaultText(action.formattedContext);
|
||||
setSendDialogDefaultText(chip ? undefined : action.formattedContext);
|
||||
setSendDialogDefaultChip(chip);
|
||||
setSendDialogRecipient(undefined);
|
||||
setReplyQuote(undefined);
|
||||
setSendDialogOpen(true);
|
||||
} else if (action.type === 'createTask') {
|
||||
openCreateTaskDialog('', action.formattedContext);
|
||||
if (chip) {
|
||||
setCreateTaskDialog({
|
||||
open: true,
|
||||
defaultSubject: '',
|
||||
defaultDescription: '',
|
||||
defaultOwner: '',
|
||||
defaultStartImmediately: undefined,
|
||||
defaultChip: chip,
|
||||
});
|
||||
} else {
|
||||
openCreateTaskDialog('', action.formattedContext);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -805,13 +833,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
<span className="max-w-60 truncate font-mono">
|
||||
{formatProjectPath(data.config.projectPath)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditorOpen(true)}
|
||||
className="ml-1 rounded px-1 py-0.5 text-[10px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
|
||||
title="Open in Editor"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setEditorOpen(true)}
|
||||
className="ml-1 flex items-center gap-0.5 rounded border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
<Code size={10} className="shrink-0" /> Edit code
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open project in built-in editor</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
{leadBranch && (
|
||||
|
|
@ -921,6 +953,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
onSendMessage={(member) => {
|
||||
setSendDialogRecipient(member.name);
|
||||
setSendDialogDefaultText(undefined);
|
||||
setSendDialogDefaultChip(undefined);
|
||||
setReplyQuote(undefined);
|
||||
setSendDialogOpen(true);
|
||||
}}
|
||||
|
|
@ -1238,6 +1271,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
onReplyToMessage={(message) => {
|
||||
setSendDialogRecipient(message.from);
|
||||
setSendDialogDefaultText(undefined);
|
||||
setSendDialogDefaultChip(undefined);
|
||||
setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) });
|
||||
setSendDialogOpen(true);
|
||||
}}
|
||||
|
|
@ -1288,6 +1322,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
setSelectedMember(null);
|
||||
setSendDialogRecipient(name || undefined);
|
||||
setSendDialogDefaultText(undefined);
|
||||
setSendDialogDefaultChip(undefined);
|
||||
setReplyQuote(undefined);
|
||||
setSendDialogOpen(true);
|
||||
}}
|
||||
|
|
@ -1342,6 +1377,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
defaultDescription={createTaskDialog.defaultDescription}
|
||||
defaultOwner={createTaskDialog.defaultOwner}
|
||||
defaultStartImmediately={createTaskDialog.defaultStartImmediately}
|
||||
defaultChip={createTaskDialog.defaultChip}
|
||||
onClose={closeCreateTaskDialog}
|
||||
onSubmit={handleCreateTask}
|
||||
submitting={creatingTask}
|
||||
|
|
@ -1450,6 +1486,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
members={activeMembers}
|
||||
defaultRecipient={sendDialogRecipient}
|
||||
defaultText={sendDialogDefaultText}
|
||||
defaultChip={sendDialogDefaultChip}
|
||||
quotedMessage={replyQuote}
|
||||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
|
|
@ -1474,6 +1511,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
setSendDialogOpen(false);
|
||||
setReplyQuote(undefined);
|
||||
setSendDialogDefaultText(undefined);
|
||||
setSendDialogDefaultChip(undefined);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -1524,6 +1562,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
memberName={reviewDialogState.memberName}
|
||||
taskId={reviewDialogState.taskId}
|
||||
initialFilePath={reviewDialogState.initialFilePath}
|
||||
projectPath={data.config.projectPath}
|
||||
onEditorAction={handleEditorAction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -541,7 +541,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
const renderHeader = (): React.JSX.Element => (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text)]">Teams</h2>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text)]">Select Team</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -317,7 +317,12 @@ export const ActivityItem = ({
|
|||
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
|
||||
→
|
||||
</span>
|
||||
<MemberBadge name={message.to} color={recipientColor} onClick={onMemberNameClick} />
|
||||
<MemberBadge
|
||||
name={message.to}
|
||||
color={recipientColor}
|
||||
hideAvatar={message.to === 'user'}
|
||||
onClick={onMemberNameClick}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const MessageRowWithObserver = ({
|
|||
memberColor,
|
||||
recipientColor,
|
||||
isUnread,
|
||||
isNew,
|
||||
onMemberNameClick,
|
||||
onCreateTask,
|
||||
onReply,
|
||||
|
|
@ -46,6 +47,7 @@ const MessageRowWithObserver = ({
|
|||
memberColor?: string;
|
||||
recipientColor?: string;
|
||||
isUnread?: boolean;
|
||||
isNew?: boolean;
|
||||
onMemberNameClick?: (name: string) => void;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
|
|
@ -83,7 +85,7 @@ const MessageRowWithObserver = ({
|
|||
}, [onVisible]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="min-h-px">
|
||||
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
|
||||
<ActivityItem
|
||||
message={message}
|
||||
teamName={teamName}
|
||||
|
|
@ -113,6 +115,11 @@ export const ActivityTimeline = ({
|
|||
}: ActivityTimelineProps): React.JSX.Element => {
|
||||
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
||||
|
||||
// --- New-message animation tracking ---
|
||||
const knownKeysRef = useRef<Set<string>>(new Set<string>());
|
||||
const isInitializedRef = useRef(false);
|
||||
const prevVisibleCountRef = useRef(visibleCount);
|
||||
|
||||
// Track whether the user was seeing ALL messages (no hidden ones).
|
||||
// If so, auto-expand when new messages push count past the limit,
|
||||
// so previously visible messages don't silently disappear.
|
||||
|
|
@ -152,19 +159,59 @@ export const ActivityTimeline = ({
|
|||
|
||||
// Auto-expand when user was seeing all and new messages arrive — derived state sync.
|
||||
// Reading/updating ref during render is intentional (React docs: derived state sync).
|
||||
/* eslint-disable react-hooks/refs -- ref stores previous frame's "showing all" for derived state sync */
|
||||
/* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
|
||||
|
||||
const wasShowingAll = wasShowingAllRef.current;
|
||||
if (wasShowingAll && hiddenCount > 0) {
|
||||
setVisibleCount(messages.length);
|
||||
}
|
||||
wasShowingAllRef.current = hiddenCount === 0;
|
||||
/* eslint-enable react-hooks/refs -- end of intentional ref access during render */
|
||||
|
||||
const visibleMessages = useMemo(
|
||||
() => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages),
|
||||
[messages, visibleCount, hiddenCount]
|
||||
);
|
||||
|
||||
// Determine which messages are "new" (should animate).
|
||||
|
||||
const newMessageKeys = useMemo(() => {
|
||||
const getKey = (msg: InboxMessage, idx: number): string =>
|
||||
`${msg.messageId ?? idx}-${msg.timestamp}-${msg.from}`;
|
||||
|
||||
// First render: seed known keys, no animations
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
for (let i = 0; i < visibleMessages.length; i++) {
|
||||
knownKeysRef.current.add(getKey(visibleMessages[i], i));
|
||||
}
|
||||
prevVisibleCountRef.current = visibleCount;
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// Pagination expansion ("Show more" / "Show all"): add keys silently
|
||||
const isPaginationExpansion = visibleCount > prevVisibleCountRef.current;
|
||||
prevVisibleCountRef.current = visibleCount;
|
||||
|
||||
if (isPaginationExpansion) {
|
||||
for (let i = 0; i < visibleMessages.length; i++) {
|
||||
knownKeysRef.current.add(getKey(visibleMessages[i], i));
|
||||
}
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// Normal update: unknown keys are new messages
|
||||
const newKeys = new Set<string>();
|
||||
for (let i = 0; i < visibleMessages.length; i++) {
|
||||
const key = getKey(visibleMessages[i], i);
|
||||
if (!knownKeysRef.current.has(key)) {
|
||||
newKeys.add(key);
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
}
|
||||
return newKeys;
|
||||
}, [visibleMessages, visibleCount]);
|
||||
/* eslint-enable react-hooks/refs -- end animation tracking block */
|
||||
|
||||
const handleShowMore = (): void => {
|
||||
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
||||
};
|
||||
|
|
@ -203,6 +250,7 @@ export const ActivityTimeline = ({
|
|||
memberColor={info?.color}
|
||||
recipientColor={recipientColor}
|
||||
isUnread={isUnread}
|
||||
isNew={newMessageKeys.has(messageKey)}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
onReply={onReplyToMessage}
|
||||
|
|
|
|||
|
|
@ -22,11 +22,16 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { AlertTriangle, Search } from 'lucide-react';
|
||||
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
@ -40,6 +45,7 @@ interface CreateTaskDialogProps {
|
|||
defaultDescription?: string;
|
||||
defaultOwner?: string;
|
||||
defaultStartImmediately?: boolean;
|
||||
defaultChip?: InlineChip;
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
subject: string,
|
||||
|
|
@ -63,16 +69,19 @@ export const CreateTaskDialog = ({
|
|||
defaultDescription = '',
|
||||
defaultOwner = '',
|
||||
defaultStartImmediately,
|
||||
defaultChip,
|
||||
onClose,
|
||||
onSubmit,
|
||||
submitting = false,
|
||||
}: CreateTaskDialogProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||
const [subject, setSubject] = useState(defaultSubject);
|
||||
const descriptionDraft = useDraftPersistence({
|
||||
key: `createTask:${teamName}:description`,
|
||||
initialValue: defaultDescription || undefined,
|
||||
});
|
||||
const descChipDraft = useChipDraftPersistence(`createTask:${teamName}:descChips`);
|
||||
const [owner, setOwner] = useState<string>(defaultOwner);
|
||||
const [blockedBy, setBlockedBy] = useState<string[]>([]);
|
||||
const [related, setRelated] = useState<string[]>([]);
|
||||
|
|
@ -84,7 +93,11 @@ export const CreateTaskDialog = ({
|
|||
|
||||
if (open && !prevOpen) {
|
||||
setSubject(defaultSubject);
|
||||
if (defaultDescription) {
|
||||
if (defaultChip) {
|
||||
const token = chipToken(defaultChip);
|
||||
descriptionDraft.setValue(token + '\n');
|
||||
descChipDraft.setChips([defaultChip]);
|
||||
} else if (defaultDescription) {
|
||||
descriptionDraft.setValue(defaultDescription);
|
||||
}
|
||||
setOwner(defaultOwner);
|
||||
|
|
@ -130,11 +143,23 @@ export const CreateTaskDialog = ({
|
|||
);
|
||||
};
|
||||
|
||||
const handleDescChipRemove = (chipId: string): void => {
|
||||
const chip = descChipDraft.chips.find((c) => c.id === chipId);
|
||||
if (chip) {
|
||||
descriptionDraft.setValue(removeChipTokenFromText(descriptionDraft.value, chip));
|
||||
}
|
||||
descChipDraft.setChips(descChipDraft.chips.filter((c) => c.id !== chipId));
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (!canSubmit) return;
|
||||
const serializedDesc = serializeChipsWithText(
|
||||
descriptionDraft.value.trim(),
|
||||
descChipDraft.chips
|
||||
);
|
||||
onSubmit(
|
||||
subject.trim(),
|
||||
descriptionDraft.value.trim(),
|
||||
serializedDesc,
|
||||
owner || undefined,
|
||||
blockedBy.length > 0 ? blockedBy : undefined,
|
||||
related.length > 0 ? related : undefined,
|
||||
|
|
@ -142,6 +167,7 @@ export const CreateTaskDialog = ({
|
|||
startImmediately
|
||||
);
|
||||
descriptionDraft.clearDraft();
|
||||
descChipDraft.clearChipDraft();
|
||||
promptDraft.clearDraft();
|
||||
};
|
||||
|
||||
|
|
@ -239,6 +265,10 @@ export const CreateTaskDialog = ({
|
|||
value={descriptionDraft.value}
|
||||
onValueChange={descriptionDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
chips={descChipDraft.chips}
|
||||
onChipRemove={handleDescChipRemove}
|
||||
projectPath={projectPath}
|
||||
onFileChipInsert={(chip) => descChipDraft.setChips([...descChipDraft.chips, chip])}
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
footerRight={
|
||||
|
|
@ -259,6 +289,7 @@ export const CreateTaskDialog = ({
|
|||
value={promptDraft.value}
|
||||
onValueChange={promptDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
projectPath={projectPath}
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
footerRight={
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
|||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
||||
import { MembersJsonEditor } from './MembersJsonEditor';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
|
||||
|
|
@ -142,15 +143,33 @@ function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
|
|||
.filter((member): member is NonNullable<typeof member> => member !== null);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS
|
||||
const TEAM_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const MEMBER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
/** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */
|
||||
function sanitizeTeamName(name: string): string {
|
||||
let result = name
|
||||
.replace(/[^a-zA-Z0-9]/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.toLowerCase();
|
||||
// Trim leading/trailing dashes without backtracking-vulnerable regex
|
||||
while (result.startsWith('-')) result = result.slice(1);
|
||||
while (result.endsWith('-')) result = result.slice(0, -1);
|
||||
return result;
|
||||
}
|
||||
|
||||
function isValidMemberName(name: string): boolean {
|
||||
if (name.length < 1 || name.length > 128) return false;
|
||||
if (!/^[a-zA-Z0-9]/.test(name)) return false;
|
||||
return /^[a-zA-Z0-9._-]+$/.test(name);
|
||||
}
|
||||
|
||||
function validateTeamNameInline(name: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!TEAM_NAME_RE.test(trimmed) || trimmed.length > 64) {
|
||||
return 'Use kebab-case [a-z0-9-], max 64 chars';
|
||||
const sanitized = sanitizeTeamName(trimmed);
|
||||
if (!sanitized) {
|
||||
return 'Name must contain at least one letter or digit';
|
||||
}
|
||||
if (sanitized.length > 128) {
|
||||
return 'Name is too long (max 128 chars)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -158,7 +177,7 @@ function validateTeamNameInline(name: string): string | null {
|
|||
function validateMemberNameInline(name: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!MEMBER_NAME_RE.test(trimmed)) {
|
||||
if (!isValidMemberName(trimmed)) {
|
||||
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -169,11 +188,20 @@ function validateRequest(
|
|||
options?: { requireCwd?: boolean }
|
||||
): ValidationResult {
|
||||
const requireCwd = options?.requireCwd ?? true;
|
||||
if (!TEAM_NAME_RE.test(request.teamName) || request.teamName.length > 64) {
|
||||
const sanitized = sanitizeTeamName(request.teamName);
|
||||
if (!sanitized) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: {
|
||||
teamName: 'Use kebab-case [a-z0-9-], max 64 chars',
|
||||
teamName: 'Name must contain at least one letter or digit',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (sanitized.length > 128) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: {
|
||||
teamName: 'Name is too long (max 128 chars)',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -201,7 +229,7 @@ function validateRequest(
|
|||
},
|
||||
};
|
||||
}
|
||||
if (request.members.some((member) => !MEMBER_NAME_RE.test(member.name.trim()))) {
|
||||
if (request.members.some((member) => !isValidMemberName(member.name.trim()))) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: {
|
||||
|
|
@ -257,11 +285,26 @@ export const CreateTeamDialog = ({
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [launchTeam, setLaunchTeam] = useState(true);
|
||||
const [teamColor, setTeamColor] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [selectedModel, setSelectedModelRaw] = useState(
|
||||
() => localStorage.getItem('team:lastSelectedModel') ?? ''
|
||||
);
|
||||
const [extendedContext, setExtendedContextRaw] = useState(
|
||||
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
||||
);
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
||||
const setSelectedModel = (value: string): void => {
|
||||
setSelectedModelRaw(value);
|
||||
localStorage.setItem('team:lastSelectedModel', value);
|
||||
};
|
||||
|
||||
const setExtendedContext = (value: boolean): void => {
|
||||
setExtendedContextRaw(value);
|
||||
localStorage.setItem('team:lastExtendedContext', String(value));
|
||||
};
|
||||
|
||||
const resetUIState = (): void => {
|
||||
setLocalError(null);
|
||||
setFieldErrors({});
|
||||
|
|
@ -281,7 +324,6 @@ export const CreateTeamDialog = ({
|
|||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setLaunchTeam(true);
|
||||
setSelectedModel('');
|
||||
setJsonEditorOpen(false);
|
||||
setJsonText('');
|
||||
setJsonError(null);
|
||||
|
|
@ -516,12 +558,19 @@ export const CreateTeamDialog = ({
|
|||
[members]
|
||||
);
|
||||
|
||||
const effectiveModel =
|
||||
selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
|
||||
const effectiveModel = useMemo(() => {
|
||||
const base = selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
|
||||
if (!extendedContext) return base;
|
||||
// 1M context is only supported for opus and sonnet
|
||||
if (base === 'haiku') return base;
|
||||
return base ? `${base}[1m]` : 'sonnet[1m]';
|
||||
}, [selectedModel, extendedContext]);
|
||||
|
||||
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||
|
||||
const request = useMemo<TeamCreateRequest>(
|
||||
() => ({
|
||||
teamName: teamName.trim(),
|
||||
teamName: sanitizedTeamName,
|
||||
description: description.trim() || undefined,
|
||||
color: teamColor || undefined,
|
||||
members: buildMembers(members),
|
||||
|
|
@ -529,7 +578,7 @@ export const CreateTeamDialog = ({
|
|||
prompt: prompt.trim() || undefined,
|
||||
model: effectiveModel,
|
||||
}),
|
||||
[teamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel]
|
||||
[sanitizedTeamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel]
|
||||
);
|
||||
|
||||
const activeError = localError ?? provisioningError;
|
||||
|
|
@ -576,7 +625,7 @@ export const CreateTeamDialog = ({
|
|||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (existingTeamNames.includes(request.teamName)) {
|
||||
if (existingTeamNames.includes(sanitizedTeamName)) {
|
||||
setFieldErrors({ teamName: 'Team name already exists' });
|
||||
setLocalError('Check form fields');
|
||||
return;
|
||||
|
|
@ -600,6 +649,7 @@ export const CreateTeamDialog = ({
|
|||
description: request.description,
|
||||
color: request.color,
|
||||
members: request.members,
|
||||
cwd: effectiveCwd || undefined,
|
||||
});
|
||||
onOpenTeam(request.teamName, effectiveCwd || undefined);
|
||||
resetFormState();
|
||||
|
|
@ -710,13 +760,18 @@ export const CreateTeamDialog = ({
|
|||
onChange={(event) => setTeamName(event.target.value)}
|
||||
placeholder="team-alpha"
|
||||
/>
|
||||
{existingTeamNames.includes(teamName.trim()) ? (
|
||||
{existingTeamNames.includes(sanitizedTeamName) ? (
|
||||
<p className="text-[11px] text-red-300">Team name already exists</p>
|
||||
) : validateTeamNameInline(teamName) ? (
|
||||
<p className="text-[11px] text-red-300">{validateTeamNameInline(teamName)}</p>
|
||||
) : fieldErrors.teamName ? (
|
||||
<p className="text-[11px] text-red-300">{fieldErrors.teamName}</p>
|
||||
) : null}
|
||||
{sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
On disk: <span className="font-mono">{sanitizedTeamName}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
|
|
@ -870,6 +925,7 @@ export const CreateTeamDialog = ({
|
|||
value={prompt}
|
||||
onValueChange={promptDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
projectPath={effectiveCwd || null}
|
||||
placeholder="Instructions for the team lead during provisioning..."
|
||||
footerRight={
|
||||
promptDraft.isSaved ? (
|
||||
|
|
@ -881,19 +937,27 @@ export const CreateTeamDialog = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="label-optional">Model (optional)</Label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Default (account setting)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
||||
<SelectItem value="opus">Opus 4.6</SelectItem>
|
||||
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
||||
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label className="label-optional shrink-0">Model (optional)</Label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[180px] text-xs">
|
||||
<SelectValue placeholder="Default (account setting)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
||||
<SelectItem value="opus">Opus 4.6</SelectItem>
|
||||
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
||||
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ExtendedContextCheckbox
|
||||
id="create-extended-context"
|
||||
checked={extendedContext}
|
||||
onCheckedChange={setExtendedContext}
|
||||
disabled={selectedModel === 'haiku'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface ExtendedContextCheckboxProps {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = ({
|
||||
id,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled = false,
|
||||
}) => (
|
||||
<>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked && !disabled}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(value) => onCheckedChange(value === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={`flex cursor-pointer items-center gap-1.5 text-xs font-normal ${
|
||||
disabled ? 'cursor-not-allowed text-text-muted opacity-50' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
Extended context (1M tokens)
|
||||
{disabled && <span className="text-[10px] italic">(not available for this model)</span>}
|
||||
</Label>
|
||||
</div>
|
||||
{checked && (
|
||||
<div className="mt-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" />
|
||||
<div className="space-y-1 text-amber-300/90">
|
||||
<p>
|
||||
Beyond 200K tokens, premium pricing applies: 2x input cost, 1.5x output cost. For
|
||||
subscribers, extra usage is billed separately.
|
||||
</p>
|
||||
<p>
|
||||
Requires API tier 4+ or extra usage enabled.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2 hover:text-amber-200"
|
||||
onClick={() =>
|
||||
window.electronAPI.openExternal(
|
||||
'https://platform.claude.com/docs/en/build-with-claude/context-windows#1m-token-context-window'
|
||||
)
|
||||
}
|
||||
>
|
||||
Learn more
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import {
|
||||
|
|
@ -65,9 +66,24 @@ export const LaunchTeamDialog = ({
|
|||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [selectedModel, setSelectedModelRaw] = useState(
|
||||
() => localStorage.getItem('team:lastSelectedModel') ?? ''
|
||||
);
|
||||
const [extendedContext, setExtendedContextRaw] = useState(
|
||||
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
||||
);
|
||||
const [clearContext, setClearContext] = useState(false);
|
||||
|
||||
const setSelectedModel = (value: string): void => {
|
||||
setSelectedModelRaw(value);
|
||||
localStorage.setItem('team:lastSelectedModel', value);
|
||||
};
|
||||
|
||||
const setExtendedContext = (value: boolean): void => {
|
||||
setExtendedContextRaw(value);
|
||||
localStorage.setItem('team:lastExtendedContext', String(value));
|
||||
};
|
||||
|
||||
const resetFormState = (): void => {
|
||||
setLocalError(null);
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -77,7 +93,6 @@ export const LaunchTeamDialog = ({
|
|||
setCwdMode('project');
|
||||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setSelectedModel('');
|
||||
setClearContext(false);
|
||||
};
|
||||
|
||||
|
|
@ -240,7 +255,12 @@ export const LaunchTeamDialog = ({
|
|||
teamName,
|
||||
cwd: effectiveCwd,
|
||||
prompt: promptDraft.value.trim() || undefined,
|
||||
model: selectedModel || undefined,
|
||||
model: (() => {
|
||||
if (!extendedContext) return selectedModel || undefined;
|
||||
// 1M context is only supported for opus and sonnet
|
||||
if (selectedModel === 'haiku') return selectedModel;
|
||||
return selectedModel ? `${selectedModel}[1m]` : 'sonnet[1m]';
|
||||
})(),
|
||||
clearContext: clearContext || undefined,
|
||||
});
|
||||
resetFormState();
|
||||
|
|
@ -344,6 +364,7 @@ export const LaunchTeamDialog = ({
|
|||
value={promptDraft.value}
|
||||
onValueChange={promptDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
projectPath={effectiveCwd || null}
|
||||
placeholder="Instructions for team lead... Use @ to mention team members."
|
||||
footerRight={
|
||||
promptDraft.isSaved ? (
|
||||
|
|
@ -353,30 +374,38 @@ export const LaunchTeamDialog = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="label-optional">Model (optional)</Label>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{[
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'opus', label: 'Opus 4.6' },
|
||||
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
selectedModel === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => setSelectedModel(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label className="label-optional shrink-0">Model (optional)</Label>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{[
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'opus', label: 'Opus 4.6' },
|
||||
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
selectedModel === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => setSelectedModel(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ExtendedContextCheckbox
|
||||
id="launch-extended-context"
|
||||
checked={extendedContext}
|
||||
onCheckedChange={setExtendedContext}
|
||||
disabled={selectedModel === 'haiku'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ export const ReviewDialog = ({
|
|||
onCancel,
|
||||
onSubmit,
|
||||
}: ReviewDialogProps): React.JSX.Element => {
|
||||
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||
const draft = useDraftPersistence({
|
||||
key: `requestChanges:${teamName}:${taskId ?? ''}`,
|
||||
enabled: Boolean(teamName && taskId),
|
||||
|
|
@ -88,7 +90,7 @@ export const ReviewDialog = ({
|
|||
onValueChange={draft.setValue}
|
||||
placeholder="Describe what needs to change..."
|
||||
suggestions={mentionSuggestions}
|
||||
hintText="Use @ to mention team members"
|
||||
projectPath={projectPath}
|
||||
footerRight={
|
||||
draft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -21,12 +22,19 @@ import {
|
|||
} from '@renderer/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { MemberBadge } from '../MemberBadge';
|
||||
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||
|
||||
|
|
@ -41,6 +49,8 @@ interface SendMessageDialogProps {
|
|||
defaultRecipient?: string;
|
||||
/** Pre-filled message text (e.g. from editor selection action) */
|
||||
defaultText?: string;
|
||||
/** Pre-filled inline code chip (from editor selection action) */
|
||||
defaultChip?: InlineChip;
|
||||
quotedMessage?: QuotedMessage;
|
||||
sending: boolean;
|
||||
sendError: string | null;
|
||||
|
|
@ -56,6 +66,7 @@ export const SendMessageDialog = ({
|
|||
members,
|
||||
defaultRecipient,
|
||||
defaultText,
|
||||
defaultChip,
|
||||
quotedMessage,
|
||||
sending,
|
||||
sendError,
|
||||
|
|
@ -64,9 +75,12 @@ export const SendMessageDialog = ({
|
|||
onClose,
|
||||
}: SendMessageDialogProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
||||
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
||||
const [member, setMember] = useState('');
|
||||
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
|
||||
const chipDraft = useChipDraftPersistence('sendMessage:chips');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [prevOpen, setPrevOpen] = useState(false);
|
||||
const [prevResult, setPrevResult] = useState<SendMessageResult | null>(null);
|
||||
|
|
@ -76,8 +90,13 @@ export const SendMessageDialog = ({
|
|||
setMember(defaultRecipient ?? '');
|
||||
setSummary('');
|
||||
setQuote(quotedMessage);
|
||||
setQuoteExpanded(false);
|
||||
setPrevResult(lastResult);
|
||||
if (defaultText) {
|
||||
if (defaultChip) {
|
||||
const token = chipToken(defaultChip);
|
||||
textDraft.setValue(token + '\n');
|
||||
chipDraft.setChips([defaultChip]);
|
||||
} else if (defaultText) {
|
||||
textDraft.setValue(defaultText);
|
||||
}
|
||||
}
|
||||
|
|
@ -98,12 +117,16 @@ export const SendMessageDialog = ({
|
|||
useEffect(() => {
|
||||
if (pendingAutoClose) {
|
||||
textDraft.clearDraft();
|
||||
chipDraft.clearChipDraft();
|
||||
setPendingAutoClose(false);
|
||||
onClose();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only trigger on pendingAutoClose flag
|
||||
}, [pendingAutoClose]);
|
||||
|
||||
const QUOTE_COLLAPSE_THRESHOLD = 120;
|
||||
const isQuoteLong = (quote?.text.length ?? 0) > QUOTE_COLLAPSE_THRESHOLD;
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members.map((m) => ({
|
||||
|
|
@ -121,12 +144,21 @@ export const SendMessageDialog = ({
|
|||
summary.trim().length > 0 &&
|
||||
!sending;
|
||||
|
||||
const handleChipRemove = (chipId: string): void => {
|
||||
const chip = chipDraft.chips.find((c) => c.id === chipId);
|
||||
if (chip) {
|
||||
textDraft.setValue(removeChipTokenFromText(textDraft.value, chip));
|
||||
}
|
||||
chipDraft.setChips(chipDraft.chips.filter((c) => c.id !== chipId));
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (!canSend) return;
|
||||
const rawText = textDraft.value.trim();
|
||||
const finalText = quote ? buildReplyBlock(quote.from, quote.text, rawText) : rawText;
|
||||
const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips);
|
||||
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
|
||||
onSend(member.trim(), finalText, summary.trim());
|
||||
textDraft.clearDraft();
|
||||
chipDraft.clearChipDraft();
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
|
|
@ -182,45 +214,73 @@ export const SendMessageDialog = ({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{quote ? (
|
||||
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setQuote(undefined)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Remove quote</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
Replying to @{quote.from}
|
||||
</span>
|
||||
<p className="line-clamp-3 pr-5 text-xs text-[var(--color-text-muted)]">
|
||||
{quote.text}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="smd-message">Message</Label>
|
||||
<MentionableTextarea
|
||||
id="smd-message"
|
||||
placeholder="Write your message..."
|
||||
value={textDraft.value}
|
||||
onValueChange={textDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
minRows={4}
|
||||
maxRows={12}
|
||||
footerRight={
|
||||
textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||
{quote ? (
|
||||
<div className="relative overflow-hidden rounded-t-md border border-b-0 border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
|
||||
{/* Decorative quotation mark */}
|
||||
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[64px] leading-none text-blue-400/[0.08]">
|
||||
“
|
||||
</span>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1.5 z-10 rounded p-0.5 text-blue-300/40 hover:text-blue-200"
|
||||
onClick={() => setQuote(undefined)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Remove quote</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-blue-300/60">Replying to</span>
|
||||
<MemberBadge name={quote.from} color={colorMap.get(quote.from)} size="sm" />
|
||||
</div>
|
||||
<div
|
||||
className={`pr-5 opacity-50 ${quoteExpanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}
|
||||
>
|
||||
<MarkdownViewer
|
||||
content={quote.text}
|
||||
bare
|
||||
maxHeight={quoteExpanded ? 'max-h-48' : 'max-h-[3.75rem]'}
|
||||
/>
|
||||
</div>
|
||||
{isQuoteLong ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 text-[10px] text-blue-400/60 hover:text-blue-300"
|
||||
onClick={() => setQuoteExpanded((v) => !v)}
|
||||
>
|
||||
{quoteExpanded ? 'less' : 'more'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<MentionableTextarea
|
||||
id="smd-message"
|
||||
className={quote ? 'rounded-t-none' : undefined}
|
||||
placeholder="Write your message..."
|
||||
value={textDraft.value}
|
||||
onValueChange={textDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
chips={chipDraft.chips}
|
||||
onChipRemove={handleChipRemove}
|
||||
projectPath={projectPath}
|
||||
onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])}
|
||||
minRows={4}
|
||||
maxRows={12}
|
||||
footerRight={
|
||||
textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const TaskCommentInput = ({
|
|||
}: TaskCommentInputProps): React.JSX.Element => {
|
||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||
const addingComment = useStore((s) => s.addingComment);
|
||||
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||
|
||||
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
|
|
@ -109,6 +110,7 @@ export const TaskCommentInput = ({
|
|||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
projectPath={projectPath}
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||
|
|
@ -54,11 +54,16 @@ export const TaskCommentsSection = ({
|
|||
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset local state when team/task changes (React-recommended pattern for
|
||||
// adjusting state based on props without using effects or refs during render)
|
||||
const currentKey = teamIdKey(teamName, taskId);
|
||||
const [prevKey, setPrevKey] = useState(currentKey);
|
||||
if (prevKey !== currentKey) {
|
||||
setPrevKey(currentKey);
|
||||
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
||||
setExpandedCommentIds(new Set());
|
||||
setReplyTo(null);
|
||||
}, [teamIdKey(teamName, taskId)]);
|
||||
}
|
||||
|
||||
const toggleCommentExpanded = useCallback((commentId: string) => {
|
||||
setExpandedCommentIds((prev) => {
|
||||
|
|
|
|||
|
|
@ -598,6 +598,7 @@ export const TaskDetailDialog = ({
|
|||
taskId={currentTask.id}
|
||||
taskOwner={currentTask.owner}
|
||||
taskStatus={currentTask.status}
|
||||
taskWorkIntervals={currentTask.workIntervals}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { defaultKeymap, history, historyKeymap, redo, undo } from '@codemirror/commands';
|
||||
import { bracketMatching, indentOnInput, syntaxHighlighting } from '@codemirror/language';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import {
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import { gotoLine, search, searchKeymap } from '@codemirror/search';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import {
|
||||
|
|
@ -20,11 +26,16 @@ import {
|
|||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import {
|
||||
createSearchPanel,
|
||||
editorSearchPanelTheme,
|
||||
} from '@renderer/components/team/editor/EditorSearchPanel';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getAsyncLanguageDesc,
|
||||
getSyncLanguageExtension,
|
||||
} from '@renderer/utils/codemirrorLanguages';
|
||||
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
|
||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
|
||||
|
|
@ -40,9 +51,6 @@ const DIRTY_DEBOUNCE_MS = 300;
|
|||
const AUTOSAVE_DELAY_MS = 30_000;
|
||||
const MAX_DRAFT_SIZE = 500 * 1024; // 500KB
|
||||
const MAX_DRAFTS = 10;
|
||||
const SELECTION_DEBOUNCE_MS = 150;
|
||||
const MAX_SELECTION_TEXT = 5000;
|
||||
|
||||
/** Compartment for dynamic line wrap toggling */
|
||||
const lineWrapCompartment = new Compartment();
|
||||
|
||||
|
|
@ -65,35 +73,8 @@ interface CodeMirrorEditorProps {
|
|||
onDraftRecovered?: (filePath: string) => void;
|
||||
/** Called when text selection changes (for floating action menu) */
|
||||
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,
|
||||
},
|
||||
};
|
||||
/** Called with the current document text on changes (debounced, for live preview) */
|
||||
onDocChange?: (content: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -122,12 +103,14 @@ function buildEditableExtensions(
|
|||
highlightActiveLineGutter(),
|
||||
bracketMatching(),
|
||||
indentOnInput(),
|
||||
foldGutter(),
|
||||
|
||||
// History
|
||||
history(),
|
||||
|
||||
// Search (Cmd+F)
|
||||
search(),
|
||||
// Search (Cmd+F) — custom panel with UI Kit
|
||||
search({ createPanel: createSearchPanel }),
|
||||
editorSearchPanelTheme,
|
||||
|
||||
// Save keymap (Cmd+S / Ctrl+S)
|
||||
keymap.of([
|
||||
|
|
@ -150,7 +133,13 @@ function buildEditableExtensions(
|
|||
]),
|
||||
|
||||
// Keymaps
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||
// Filter out built-in gotoLine (Alt-g) — replaced by custom GoToLineDialog
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap.filter((k) => k.run !== gotoLine),
|
||||
...foldKeymap,
|
||||
]),
|
||||
|
||||
// Update listener for dirty flag + cursor position + selection
|
||||
EditorView.updateListener.of((update) => {
|
||||
|
|
@ -241,6 +230,8 @@ function enforceDraftLimit(): void {
|
|||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const DOC_CHANGE_DEBOUNCE_MS = 150;
|
||||
|
||||
export const CodeMirrorEditor = ({
|
||||
filePath,
|
||||
content,
|
||||
|
|
@ -249,6 +240,7 @@ export const CodeMirrorEditor = ({
|
|||
onCursorChange,
|
||||
onDraftRecovered,
|
||||
onSelectionChange,
|
||||
onDocChange,
|
||||
}: CodeMirrorEditorProps): React.ReactElement => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
|
@ -262,6 +254,8 @@ export const CodeMirrorEditor = ({
|
|||
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Selection debounce
|
||||
const selectionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Doc change debounce (live preview)
|
||||
const docChangeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const markFileModified = useStore((s) => s.markFileModified);
|
||||
const discardChanges = useStore((s) => s.discardChanges);
|
||||
|
|
@ -281,6 +275,9 @@ export const CodeMirrorEditor = ({
|
|||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
|
||||
const onDocChangeRef = useRef(onDocChange);
|
||||
onDocChangeRef.current = onDocChange;
|
||||
|
||||
const lineWrapRef = useRef(lineWrap);
|
||||
lineWrapRef.current = lineWrap;
|
||||
|
||||
|
|
@ -303,6 +300,13 @@ export const CodeMirrorEditor = ({
|
|||
saveDraft(filePathRef.current, view.state.doc.toString());
|
||||
}
|
||||
}, AUTOSAVE_DELAY_MS);
|
||||
|
||||
// Live content callback for markdown preview
|
||||
if (docChangeTimerRef.current) clearTimeout(docChangeTimerRef.current);
|
||||
docChangeTimerRef.current = setTimeout(() => {
|
||||
const view = viewRef.current;
|
||||
if (view) onDocChangeRef.current?.(view.state.doc.toString());
|
||||
}, DOC_CHANGE_DEBOUNCE_MS);
|
||||
}, [markFileModified]);
|
||||
|
||||
const handleCursorMove = useCallback((line: number, col: number) => {
|
||||
|
|
@ -442,6 +446,7 @@ export const CodeMirrorEditor = ({
|
|||
const dirtyTimer = dirtyTimerRef;
|
||||
const autosaveTimer = autosaveTimerRef;
|
||||
const selectionTimer = selectionTimerRef;
|
||||
const docChangeTimer = docChangeTimerRef;
|
||||
|
||||
return () => {
|
||||
// Save scroll position before destroying
|
||||
|
|
@ -454,6 +459,7 @@ export const CodeMirrorEditor = ({
|
|||
if (dirtyTimer.current) clearTimeout(dirtyTimer.current);
|
||||
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
|
||||
if (selectionTimer.current) clearTimeout(selectionTimer.current);
|
||||
if (docChangeTimer.current) clearTimeout(docChangeTimer.current);
|
||||
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
|
|
@ -496,5 +502,5 @@ export const CodeMirrorEditor = ({
|
|||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={containerRef} className="size-full overflow-auto" />;
|
||||
return <div ref={containerRef} className="size-full overflow-hidden" />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Placeholder for non-previewable binary files — shows file info and "Open in System Viewer" button.
|
||||
*/
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { FileQuestion } from 'lucide-react';
|
||||
|
||||
interface EditorBinaryPlaceholderProps {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const EditorBinaryPlaceholder = ({
|
||||
filePath,
|
||||
fileName,
|
||||
size,
|
||||
}: EditorBinaryPlaceholderProps): React.ReactElement => {
|
||||
const projectPath = useStore((s) => s.editorProjectPath);
|
||||
const sizeFormatted =
|
||||
size < 1024
|
||||
? `${size} B`
|
||||
: size < 1024 * 1024
|
||||
? `${(size / 1024).toFixed(1)} KB`
|
||||
: `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
|
||||
const handleOpenExternal = (): void => {
|
||||
window.electronAPI.openPath(filePath, projectPath ?? undefined).catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||
<FileQuestion className="size-12 opacity-30" />
|
||||
<p className="text-sm font-medium text-text-secondary">{fileName}</p>
|
||||
<p className="text-xs">Binary file ({sizeFormatted})</p>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={handleOpenExternal}>
|
||||
Open in System Viewer
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
/**
|
||||
* Placeholder for binary files — shows file info and "Open in System Viewer" button.
|
||||
* Router for binary file display — picks the right preview component
|
||||
* based on file type from the preview registry.
|
||||
*/
|
||||
|
||||
import { FileQuestion } from 'lucide-react';
|
||||
import { getPreviewType, isPreviewable } from '@renderer/utils/previewRegistry';
|
||||
|
||||
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
|
||||
import { EditorImagePreview } from './EditorImagePreview';
|
||||
|
||||
interface EditorBinaryStateProps {
|
||||
filePath: string;
|
||||
|
|
@ -14,28 +18,11 @@ export const EditorBinaryState = ({
|
|||
size,
|
||||
}: EditorBinaryStateProps): React.ReactElement => {
|
||||
const fileName = filePath.split('/').pop() ?? filePath;
|
||||
const sizeFormatted =
|
||||
size < 1024
|
||||
? `${size} B`
|
||||
: size < 1024 * 1024
|
||||
? `${(size / 1024).toFixed(1)} KB`
|
||||
: `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
const previewType = getPreviewType(fileName);
|
||||
|
||||
const handleOpenExternal = (): void => {
|
||||
window.electronAPI.openPath(filePath).catch(console.error);
|
||||
};
|
||||
if (previewType === 'image' && isPreviewable(fileName, size)) {
|
||||
return <EditorImagePreview filePath={filePath} fileName={fileName} size={size} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||
<FileQuestion className="size-12 opacity-30" />
|
||||
<p className="text-sm font-medium text-text-secondary">{fileName}</p>
|
||||
<p className="text-xs">Binary file ({sizeFormatted})</p>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return <EditorBinaryPlaceholder filePath={filePath} fileName={fileName} size={size} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,20 +4,25 @@
|
|||
* Each segment is clickable — expands and scrolls the folder in the file tree.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { getFileIcon } from './fileIcons';
|
||||
import { FileIcon } from './FileIcon';
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const EditorBreadcrumb = (): React.ReactElement | null => {
|
||||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||
const projectPath = useStore((s) => s.editorProjectPath);
|
||||
const { activeTabId, projectPath } = useStore(
|
||||
useShallow((s) => ({
|
||||
activeTabId: s.editorActiveTabId,
|
||||
projectPath: s.editorProjectPath,
|
||||
}))
|
||||
);
|
||||
const expandDirectory = useStore((s) => s.expandDirectory);
|
||||
|
||||
const segments = useMemo(() => {
|
||||
|
|
@ -30,19 +35,19 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
|
|||
return relativePath.split('/');
|
||||
}, [activeTabId, projectPath]);
|
||||
|
||||
const handleSegmentClick = useCallback(
|
||||
(segmentIndex: number): void => {
|
||||
if (!projectPath) return;
|
||||
const dirSegments = segments.slice(0, segmentIndex + 1);
|
||||
const dirPath = `${projectPath}/${dirSegments.join('/')}`;
|
||||
void expandDirectory(dirPath);
|
||||
},
|
||||
[segments, projectPath, expandDirectory]
|
||||
);
|
||||
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const fileName = segments[segments.length - 1];
|
||||
const iconInfo = getFileIcon(fileName);
|
||||
const Icon = iconInfo.icon;
|
||||
|
||||
const handleSegmentClick = (segmentIndex: number): void => {
|
||||
if (!projectPath) return;
|
||||
// Build absolute path up to this segment (it's a directory)
|
||||
const dirSegments = segments.slice(0, segmentIndex + 1);
|
||||
const dirPath = `${projectPath}/${dirSegments.join('/')}`;
|
||||
void expandDirectory(dirPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 overflow-x-auto px-3 py-1 text-xs text-text-muted">
|
||||
|
|
@ -53,7 +58,7 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
|
|||
{idx > 0 && <ChevronRight className="text-text-muted/50 size-3" />}
|
||||
{isLast ? (
|
||||
<span className="flex items-center gap-1 text-text-secondary">
|
||||
<Icon className="size-3" style={{ color: iconInfo.color }} />
|
||||
<FileIcon fileName={fileName} className="size-3" />
|
||||
{segment}
|
||||
</span>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -9,7 +9,16 @@
|
|||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
import { FilePlus, FolderOpen, FolderPlus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ClipboardCopy,
|
||||
FilePlus,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
|
|
@ -23,9 +32,15 @@ interface TargetEntry {
|
|||
|
||||
interface EditorContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
projectPath: string | null;
|
||||
onNewFile: (parentDir: string) => void;
|
||||
onNewFolder: (parentDir: string) => void;
|
||||
onDelete: (path: string) => void;
|
||||
onRename: (path: string) => void;
|
||||
/** Trigger "Create Task" with a file mention (files only, not directories) */
|
||||
onCreateTask?: (filePath: string) => void;
|
||||
/** Trigger "Write Teammate" with a file mention (files only, not directories) */
|
||||
onSendMessage?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -34,9 +49,13 @@ interface EditorContextMenuProps {
|
|||
|
||||
export const EditorContextMenu = ({
|
||||
children,
|
||||
projectPath,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onDelete,
|
||||
onRename,
|
||||
onCreateTask,
|
||||
onSendMessage,
|
||||
}: EditorContextMenuProps): React.ReactElement => {
|
||||
const [target, setTarget] = useState<TargetEntry | null>(null);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -71,7 +90,7 @@ export const EditorContextMenu = ({
|
|||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<div ref={triggerRef} onContextMenu={handleContextMenu}>
|
||||
<div ref={triggerRef} onContextMenu={handleContextMenu} className="h-full">
|
||||
{children}
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
|
|
@ -102,6 +121,15 @@ export const EditorContextMenu = ({
|
|||
|
||||
{target && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={target.isSensitive}
|
||||
onSelect={() => onRename(target.path)}
|
||||
>
|
||||
<Pencil className="size-3.5 text-text-muted" />
|
||||
Rename
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-red-400 outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={target.isSensitive}
|
||||
|
|
@ -116,15 +144,65 @@ export const EditorContextMenu = ({
|
|||
)}
|
||||
|
||||
{target && (
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => {
|
||||
void window.electronAPI.showInFolder(target.path);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="size-3.5 text-text-muted" />
|
||||
Reveal in Finder
|
||||
</ContextMenu.Item>
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => void navigator.clipboard.writeText(target.path)}
|
||||
>
|
||||
<ClipboardCopy className="size-3.5 text-text-muted" />
|
||||
Copy Path
|
||||
</ContextMenu.Item>
|
||||
|
||||
{projectPath && target.path.startsWith(projectPath) && (
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => {
|
||||
const relative = target.path.slice(projectPath.length + 1);
|
||||
void navigator.clipboard.writeText(relative);
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy className="size-3.5 text-text-muted" />
|
||||
Copy Relative Path
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
|
||||
<ContextMenu.Separator className="my-1 h-px bg-border" />
|
||||
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => {
|
||||
void window.electronAPI.showInFolder(target.path);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="size-3.5 text-text-muted" />
|
||||
Reveal in Finder
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Team actions — file only */}
|
||||
{target && !target.isDir && (onCreateTask || onSendMessage) && (
|
||||
<>
|
||||
<ContextMenu.Separator className="my-1 h-px bg-border" />
|
||||
{onSendMessage && (
|
||||
<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={() => onSendMessage(target.path)}
|
||||
>
|
||||
<MessageSquare className="size-3.5 text-text-muted" />
|
||||
Write Teammate
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{onCreateTask && (
|
||||
<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={() => onCreateTask(target.path)}
|
||||
>
|
||||
<ListTodo className="size-3.5 text-text-muted" />
|
||||
Create Task
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Error state for file read failures (EACCES, ENOENT, etc.).
|
||||
*/
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface EditorErrorStateProps {
|
||||
|
|
@ -25,22 +26,14 @@ export const EditorErrorState = ({
|
|||
<p className="max-w-md text-center text-sm text-text-secondary">{error}</p>
|
||||
<div className="flex gap-2">
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close Tab
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,13 +16,23 @@ import {
|
|||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { EditorContextMenu } from './EditorContextMenu';
|
||||
import { getFileIcon } from './fileIcons';
|
||||
import { FileIcon } from './FileIcon';
|
||||
import { GitStatusBadge } from './GitStatusBadge';
|
||||
import { NewFileDialog } from './NewFileDialog';
|
||||
|
||||
|
|
@ -37,6 +47,10 @@ import type { FileTreeEntry, GitFileStatusType } from '@shared/types/editor';
|
|||
interface EditorFileTreeProps {
|
||||
selectedFilePath: string | null;
|
||||
onFileSelect: (filePath: string) => void;
|
||||
/** Trigger "Create Task" with a file mention from context menu */
|
||||
onCreateTask?: (filePath: string) => void;
|
||||
/** Trigger "Write Teammate" with a file mention from context menu */
|
||||
onSendMessage?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
interface NewItemState {
|
||||
|
|
@ -64,30 +78,58 @@ const AUTO_EXPAND_DELAY_MS = 500;
|
|||
// Component
|
||||
// =============================================================================
|
||||
|
||||
// Render counter for debugging — tracks how often the tree re-renders
|
||||
let fileTreeRenderCount = 0;
|
||||
|
||||
export const EditorFileTree = ({
|
||||
selectedFilePath,
|
||||
onFileSelect,
|
||||
onCreateTask,
|
||||
onSendMessage,
|
||||
}: EditorFileTreeProps): React.ReactElement => {
|
||||
const fileTree = useStore((s) => s.editorFileTree);
|
||||
const expandedDirs = useStore((s) => s.editorExpandedDirs);
|
||||
fileTreeRenderCount++;
|
||||
if (fileTreeRenderCount % 5 === 0) {
|
||||
console.debug(`[perf] EditorFileTree render #${fileTreeRenderCount}`);
|
||||
}
|
||||
// Data selectors — grouped with useShallow to prevent unnecessary re-renders
|
||||
const { fileTree, expandedDirs, loading, error, gitFiles, projectPath } = useStore(
|
||||
useShallow((s) => ({
|
||||
fileTree: s.editorFileTree,
|
||||
expandedDirs: s.editorExpandedDirs,
|
||||
loading: s.editorFileTreeLoading,
|
||||
error: s.editorFileTreeError,
|
||||
gitFiles: s.editorGitFiles,
|
||||
projectPath: s.editorProjectPath,
|
||||
}))
|
||||
);
|
||||
|
||||
// Actions — stable references in Zustand, no grouping needed
|
||||
const expandDirectory = useStore((s) => s.expandDirectory);
|
||||
const collapseDirectory = useStore((s) => s.collapseDirectory);
|
||||
const loading = useStore((s) => s.editorFileTreeLoading);
|
||||
const error = useStore((s) => s.editorFileTreeError);
|
||||
const createFileInTree = useStore((s) => s.createFileInTree);
|
||||
const createDirInTree = useStore((s) => s.createDirInTree);
|
||||
const deleteFileFromTree = useStore((s) => s.deleteFileFromTree);
|
||||
const moveFileInTree = useStore((s) => s.moveFileInTree);
|
||||
const renameFileInTree = useStore((s) => s.renameFileInTree);
|
||||
const openFile = useStore((s) => s.openFile);
|
||||
const gitFiles = useStore((s) => s.editorGitFiles);
|
||||
const projectPath = useStore((s) => s.editorProjectPath);
|
||||
|
||||
const [newItemState, setNewItemState] = useState<NewItemState | null>(null);
|
||||
const [renamingPath, setRenamingPath] = useState<string | null>(null);
|
||||
const [deleteConfirmPath, setDeleteConfirmPath] = useState<string | null>(null);
|
||||
const [draggedItem, setDraggedItem] = useState<FlatTreeItem | null>(null);
|
||||
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null);
|
||||
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Defer DnD initialization — mount tree without drag/drop first, enable after idle
|
||||
const [dndReady, setDndReady] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setDndReady(true));
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
// Cleanup auto-expand timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -101,14 +143,21 @@ export const EditorFileTree = ({
|
|||
// Convert hierarchical FileTreeEntry[] → TreeNode[] (respects entry.type)
|
||||
const treeNodes = useMemo(() => {
|
||||
if (!fileTree) return [];
|
||||
return sortTreeNodes(convertEntriesToNodes(fileTree));
|
||||
const t0 = performance.now();
|
||||
const nodes = sortTreeNodes(convertEntriesToNodes(fileTree));
|
||||
const ms = performance.now() - t0;
|
||||
if (ms > 2) console.debug(`[perf] treeNodes: ${ms.toFixed(1)}ms, nodes=${nodes.length}`);
|
||||
return nodes;
|
||||
}, [fileTree]);
|
||||
|
||||
// Flatten tree into visible items list for virtualization
|
||||
// expandedDirs is keyed by absolute path, and node.fullPath = entry.path (absolute)
|
||||
const flatItems = useMemo(() => {
|
||||
const t0 = performance.now();
|
||||
const items: FlatTreeItem[] = [];
|
||||
flattenVisible(treeNodes, expandedDirs, items, 0);
|
||||
const ms = performance.now() - t0;
|
||||
if (ms > 2) console.debug(`[perf] flatItems: ${ms.toFixed(1)}ms, items=${items.length}`);
|
||||
return items;
|
||||
}, [treeNodes, expandedDirs]);
|
||||
|
||||
|
|
@ -121,16 +170,42 @@ export const EditorFileTree = ({
|
|||
return map;
|
||||
}, [flatItems]);
|
||||
|
||||
// Virtual scrolling — increase overscan during drag for more drop targets
|
||||
// Compute insertion index for inline new-item input
|
||||
const newItemInsert = useMemo(() => {
|
||||
if (!newItemState) return null;
|
||||
const { parentDir } = newItemState;
|
||||
|
||||
const parentIdx = flatItems.findIndex((fi) => fi.node.fullPath === parentDir);
|
||||
|
||||
if (parentIdx === -1) {
|
||||
// parentDir is the project root (not a node in flatItems) — insert at top
|
||||
return { index: 0, depth: 0 };
|
||||
}
|
||||
|
||||
// Insert right after the parent directory node (top of its children)
|
||||
return { index: parentIdx + 1, depth: flatItems[parentIdx].depth + 1 };
|
||||
}, [newItemState, flatItems]);
|
||||
|
||||
// Virtual scrolling — reduced overscan during initial mount, increase during drag
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatItems.length,
|
||||
count: flatItems.length + (newItemInsert ? 1 : 0),
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ITEM_HEIGHT,
|
||||
overscan: draggedItem ? 20 : 10,
|
||||
overscan: !dndReady ? 3 : draggedItem ? 20 : 10,
|
||||
});
|
||||
|
||||
// Scroll to file when selectedFilePath changes (e.g. from revealFileInEditor)
|
||||
useEffect(() => {
|
||||
if (!selectedFilePath) return;
|
||||
const idx = flatItems.findIndex((fi) => fi.node.fullPath === selectedFilePath);
|
||||
if (idx >= 0) {
|
||||
virtualizer.scrollToIndex(idx, { align: 'center' });
|
||||
}
|
||||
}, [selectedFilePath, flatItems, virtualizer]);
|
||||
|
||||
// Git status lookup: absolute path → status type
|
||||
const gitStatusMap = useMemo(() => {
|
||||
const t0 = performance.now();
|
||||
const map = new Map<string, GitFileStatusType>();
|
||||
if (!gitFiles.length || !projectPath) return map;
|
||||
for (const file of gitFiles) {
|
||||
|
|
@ -139,6 +214,8 @@ export const EditorFileTree = ({
|
|||
: `${projectPath}/${file.path}`;
|
||||
map.set(absPath, file.status);
|
||||
}
|
||||
const ms = performance.now() - t0;
|
||||
if (ms > 2) console.debug(`[perf] gitStatusMap: ${ms.toFixed(1)}ms, files=${gitFiles.length}`);
|
||||
return map;
|
||||
}, [gitFiles, projectPath]);
|
||||
|
||||
|
|
@ -163,25 +240,58 @@ export const EditorFileTree = ({
|
|||
[onFileSelect, expandedDirs, expandDirectory, collapseDirectory]
|
||||
);
|
||||
|
||||
// Context menu handlers
|
||||
const handleNewFile = useCallback((parentDir: string) => {
|
||||
setNewItemState({ parentDir, type: 'file' });
|
||||
}, []);
|
||||
|
||||
const handleNewFolder = useCallback((parentDir: string) => {
|
||||
setNewItemState({ parentDir, type: 'directory' });
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (path: string) => {
|
||||
const fileName = path.split('/').pop() ?? path;
|
||||
const confirmed = window.confirm(`Move "${fileName}" to Trash?`);
|
||||
if (!confirmed) return;
|
||||
await deleteFileFromTree(path);
|
||||
// Context menu handlers — expand parent directory so the input appears inline
|
||||
const handleNewFile = useCallback(
|
||||
(parentDir: string) => {
|
||||
if (parentDir !== projectPath && !expandedDirs[parentDir]) {
|
||||
void expandDirectory(parentDir);
|
||||
}
|
||||
setNewItemState({ parentDir, type: 'file' });
|
||||
},
|
||||
[deleteFileFromTree]
|
||||
[projectPath, expandedDirs, expandDirectory]
|
||||
);
|
||||
|
||||
const handleNewFolder = useCallback(
|
||||
(parentDir: string) => {
|
||||
if (parentDir !== projectPath && !expandedDirs[parentDir]) {
|
||||
void expandDirectory(parentDir);
|
||||
}
|
||||
setNewItemState({ parentDir, type: 'directory' });
|
||||
},
|
||||
[projectPath, expandedDirs, expandDirectory]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback((path: string) => {
|
||||
setDeleteConfirmPath(path);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteConfirmPath) return;
|
||||
await deleteFileFromTree(deleteConfirmPath);
|
||||
setDeleteConfirmPath(null);
|
||||
}, [deleteConfirmPath, deleteFileFromTree]);
|
||||
|
||||
const handleCancelDelete = useCallback(() => {
|
||||
setDeleteConfirmPath(null);
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback((path: string) => {
|
||||
setRenamingPath(path);
|
||||
}, []);
|
||||
|
||||
const handleRenameSubmit = useCallback(
|
||||
async (newName: string) => {
|
||||
if (!renamingPath) return;
|
||||
await renameFileInTree(renamingPath, newName);
|
||||
setRenamingPath(null);
|
||||
},
|
||||
[renamingPath, renameFileInTree]
|
||||
);
|
||||
|
||||
const handleRenameCancel = useCallback(() => {
|
||||
setRenamingPath(null);
|
||||
}, []);
|
||||
|
||||
const handleNewItemSubmit = useCallback(
|
||||
async (name: string) => {
|
||||
if (!newItemState) return;
|
||||
|
|
@ -332,9 +442,13 @@ export const EditorFileTree = ({
|
|||
|
||||
return (
|
||||
<EditorContextMenu
|
||||
projectPath={projectPath}
|
||||
onNewFile={handleNewFile}
|
||||
onNewFolder={handleNewFolder}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
onCreateTask={onCreateTask}
|
||||
onSendMessage={onSendMessage}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
|
|
@ -344,7 +458,11 @@ export const EditorFileTree = ({
|
|||
onDragCancel={handleDragCancel}
|
||||
autoScroll={{ threshold: { x: 0, y: 0.15 } }}
|
||||
>
|
||||
<RootDropZone ref={scrollRef} projectPath={projectPath}>
|
||||
<RootDropZone
|
||||
ref={scrollRef}
|
||||
projectPath={projectPath}
|
||||
isDropTarget={dropTargetPath === projectPath}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
|
|
@ -353,41 +471,91 @@ export const EditorFileTree = ({
|
|||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const item = flatItems[virtualItem.index];
|
||||
const { index } = virtualItem;
|
||||
|
||||
// Render inline new-item input at the correct tree position
|
||||
if (index === newItemInsert?.index) {
|
||||
return (
|
||||
<div
|
||||
key="__new-item-input__"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${virtualItem.start}px`,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
paddingLeft: `${Math.min(newItemInsert.depth, MAX_DEPTH) * INDENT_PX}px`,
|
||||
}}
|
||||
>
|
||||
<NewFileDialog
|
||||
type={newItemState!.type}
|
||||
parentDir={newItemState!.parentDir}
|
||||
onSubmit={handleNewItemSubmit}
|
||||
onCancel={handleNewItemCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Adjust index for items after the insertion point
|
||||
const flatIdx = newItemInsert && index > newItemInsert.index ? index - 1 : index;
|
||||
const item = flatItems[flatIdx];
|
||||
|
||||
return (
|
||||
<DraggableTreeItem
|
||||
<div
|
||||
key={item.node.fullPath}
|
||||
item={item}
|
||||
activeNodePath={activeNodePath}
|
||||
gitStatusMap={gitStatusMap}
|
||||
dropTargetPath={dropTargetPath}
|
||||
isDragActive={!!draggedItem}
|
||||
onClick={handleNodeClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
top: `${virtualItem.start}px`,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DraggableTreeItem
|
||||
item={item}
|
||||
activeNodePath={activeNodePath}
|
||||
gitStatus={gitStatusMap.get(item.node.fullPath)}
|
||||
dropTargetPath={dropTargetPath}
|
||||
isDragActive={!!draggedItem}
|
||||
onClick={handleNodeClick}
|
||||
isRenaming={renamingPath === item.node.fullPath}
|
||||
onRenameSubmit={handleRenameSubmit}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Spacer at bottom — drop here to move to project root */}
|
||||
{draggedItem && (
|
||||
<div className="h-16 w-full shrink-0" aria-label="Drop here for project root" />
|
||||
)}
|
||||
</RootDropZone>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedItem && <DragOverlayFileItem item={draggedItem} />}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
{newItemState && (
|
||||
<NewFileDialog
|
||||
type={newItemState.type}
|
||||
parentDir={newItemState.parentDir}
|
||||
onSubmit={handleNewItemSubmit}
|
||||
onCancel={handleNewItemCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -398,8 +566,8 @@ export const EditorFileTree = ({
|
|||
|
||||
const RootDropZone = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{ projectPath: string | null; children: React.ReactNode }
|
||||
>(({ projectPath, children }, ref) => {
|
||||
{ projectPath: string | null; isDropTarget: boolean; children: React.ReactNode }
|
||||
>(({ projectPath, isDropTarget, children }, ref) => {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: 'root-drop-zone',
|
||||
data: { isRoot: true, path: projectPath },
|
||||
|
|
@ -417,7 +585,13 @@ const RootDropZone = React.forwardRef<
|
|||
);
|
||||
|
||||
return (
|
||||
<div ref={combinedRef} className="h-full overflow-y-auto" role="tree">
|
||||
<div
|
||||
ref={combinedRef}
|
||||
className={`scrollbar-thin h-full overflow-y-auto transition-colors ${
|
||||
isDropTarget ? 'bg-blue-400/5 ring-1 ring-inset ring-blue-400/30' : ''
|
||||
}`}
|
||||
role="tree"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -432,11 +606,13 @@ RootDropZone.displayName = 'RootDropZone';
|
|||
interface DraggableTreeItemProps {
|
||||
item: FlatTreeItem;
|
||||
activeNodePath: string | null;
|
||||
gitStatusMap: Map<string, GitFileStatusType>;
|
||||
gitStatus?: GitFileStatusType;
|
||||
dropTargetPath: string | null;
|
||||
isDragActive: boolean;
|
||||
onClick: (node: TreeNode<FileTreeEntry>) => void;
|
||||
style: React.CSSProperties;
|
||||
isRenaming?: boolean;
|
||||
onRenameSubmit?: (newName: string) => void;
|
||||
onRenameCancel?: () => void;
|
||||
}
|
||||
|
||||
/* eslint-disable react/jsx-props-no-spreading -- dnd-kit requires prop spreading for drag attributes, listeners, and data attributes */
|
||||
|
|
@ -444,11 +620,13 @@ const DraggableTreeItem = React.memo(
|
|||
({
|
||||
item,
|
||||
activeNodePath,
|
||||
gitStatusMap,
|
||||
gitStatus,
|
||||
dropTargetPath,
|
||||
isDragActive,
|
||||
onClick,
|
||||
style,
|
||||
isRenaming,
|
||||
onRenameSubmit,
|
||||
onRenameCancel,
|
||||
}: DraggableTreeItemProps): React.ReactElement => {
|
||||
const { node, depth, isExpanded } = item;
|
||||
const isSelected = activeNodePath === node.fullPath;
|
||||
|
|
@ -483,8 +661,10 @@ const DraggableTreeItem = React.memo(
|
|||
[setDragRef, setDropRef, node.isFile]
|
||||
);
|
||||
|
||||
// Visual: highlight drop target directory
|
||||
// Visual: highlight drop target directory and its visible children
|
||||
const isDropTarget = !node.isFile && dropTargetPath === node.fullPath;
|
||||
const isInsideDropTarget =
|
||||
dropTargetPath != null && node.fullPath.startsWith(dropTargetPath + '/');
|
||||
|
||||
const dataAttrs: Record<string, string> = {};
|
||||
if (node.data) {
|
||||
|
|
@ -508,9 +688,7 @@ const DraggableTreeItem = React.memo(
|
|||
if (node.data?.isSensitive) {
|
||||
icon = <Lock className="size-3.5 shrink-0 text-yellow-500" />;
|
||||
} else if (node.isFile) {
|
||||
const fileIcon = getFileIcon(node.name);
|
||||
const FileIcon = fileIcon.icon;
|
||||
icon = <FileIcon className="size-3.5 shrink-0" style={{ color: fileIcon.color }} />;
|
||||
icon = <FileIcon fileName={node.name} className="size-3.5" />;
|
||||
} else if (isExpanded) {
|
||||
icon = <FolderOpen className="size-3.5 shrink-0 text-text-muted" />;
|
||||
} else {
|
||||
|
|
@ -525,17 +703,12 @@ const DraggableTreeItem = React.memo(
|
|||
role="treeitem"
|
||||
aria-selected={node.isFile ? isSelected : undefined}
|
||||
aria-expanded={!node.isFile ? isExpanded : undefined}
|
||||
className={`flex cursor-pointer select-none items-center gap-1 truncate px-2 text-xs transition-colors hover:bg-surface-raised ${
|
||||
className={`flex h-full cursor-pointer select-none items-center gap-1 truncate px-2 text-xs transition-colors hover:bg-surface-raised ${
|
||||
isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary'
|
||||
} ${isDragging ? 'opacity-30' : ''} ${
|
||||
isDropTarget ? 'rounded bg-blue-400/10 ring-2 ring-blue-400/50' : ''
|
||||
}`}
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft: `${visualDepth * INDENT_PX + 8}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
} ${isInsideDropTarget && !isDropTarget ? 'border-l-2 border-l-blue-400/40 bg-blue-400/5' : ''}`}
|
||||
style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
|
|
@ -549,10 +722,16 @@ const DraggableTreeItem = React.memo(
|
|||
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
||||
))}
|
||||
{icon}
|
||||
<span className="truncate">{node.name}</span>
|
||||
{node.data && gitStatusMap.has(node.data.path) && (
|
||||
<GitStatusBadge status={gitStatusMap.get(node.data.path)!} />
|
||||
{isRenaming ? (
|
||||
<InlineRenameInput
|
||||
initialName={node.name}
|
||||
onSubmit={onRenameSubmit!}
|
||||
onCancel={onRenameCancel!}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate">{node.name}</span>
|
||||
)}
|
||||
{!isRenaming && gitStatus && <GitStatusBadge status={gitStatus} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -570,9 +749,7 @@ const DragOverlayFileItem = ({ item }: { item: FlatTreeItem }): React.ReactEleme
|
|||
|
||||
let icon: React.ReactNode;
|
||||
if (node.isFile) {
|
||||
const fileIcon = getFileIcon(node.name);
|
||||
const FileIcon = fileIcon.icon;
|
||||
icon = <FileIcon className="size-3.5" style={{ color: fileIcon.color }} />;
|
||||
icon = <FileIcon fileName={node.name} className="size-3.5" />;
|
||||
} else {
|
||||
icon = <FolderOpen className="size-3.5 text-text-muted" />;
|
||||
}
|
||||
|
|
@ -585,6 +762,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
|
||||
// =============================================================================
|
||||
|
|
|
|||
137
src/renderer/components/team/editor/EditorImagePreview.tsx
Normal file
137
src/renderer/components/team/editor/EditorImagePreview.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Inline image preview for the project editor.
|
||||
*
|
||||
* Loads binary file as base64 data URL via IPC, displays centered image
|
||||
* with checkerboard background for transparency, metadata, and lightbox on click.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
|
||||
|
||||
interface EditorImagePreviewProps {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const EditorImagePreview = ({
|
||||
filePath,
|
||||
fileName,
|
||||
size,
|
||||
}: EditorImagePreviewProps): React.ReactElement => {
|
||||
const projectPath = useStore((s) => s.editorProjectPath);
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Reset state when filePath changes (setState-during-render, React-approved pattern)
|
||||
const [prevFilePath, setPrevFilePath] = useState(filePath);
|
||||
if (prevFilePath !== filePath) {
|
||||
setPrevFilePath(filePath);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setDataUrl(null);
|
||||
setDimensions(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
window.electronAPI.editor
|
||||
.readBinaryPreview(filePath)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setDataUrl(`data:${result.mimeType};base64,${result.base64}`);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return;
|
||||
setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filePath]);
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
const img = imgRef.current;
|
||||
if (img) {
|
||||
setDimensions({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenExternal = useCallback((): void => {
|
||||
window.electronAPI.openPath(filePath, projectPath ?? undefined).catch(console.error);
|
||||
}, [filePath, projectPath]);
|
||||
|
||||
const sizeFormatted =
|
||||
size < 1024
|
||||
? `${size} B`
|
||||
: size < 1024 * 1024
|
||||
? `${(size / 1024).toFixed(1)} KB`
|
||||
: `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||
<Loader2 className="size-8 animate-spin opacity-40" />
|
||||
<p className="text-xs">Loading preview…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !dataUrl) {
|
||||
return <EditorBinaryPlaceholder filePath={filePath} fileName={fileName} size={size} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
|
||||
<button
|
||||
type="button"
|
||||
className="checkerboard-bg flex max-h-[60vh] max-w-[80%] cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border border-border-subtle p-1"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
aria-label="Open full-size preview"
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={dataUrl}
|
||||
alt={fileName}
|
||||
className="max-h-[60vh] object-contain"
|
||||
onLoad={handleImageLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
{fileName}
|
||||
{dimensions ? ` — ${dimensions.w}×${dimensions.h}` : ''}
|
||||
{` — ${sizeFormatted}`}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleOpenExternal}>
|
||||
Open in System Viewer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ImageLightbox
|
||||
src={dataUrl}
|
||||
alt={fileName}
|
||||
open={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
508
src/renderer/components/team/editor/EditorSearchPanel.tsx
Normal file
508
src/renderer/components/team/editor/EditorSearchPanel.tsx
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
/**
|
||||
* 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 React, { 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 = React.memo(function SearchToggleButton({
|
||||
active,
|
||||
onClick,
|
||||
tooltip,
|
||||
shortcut,
|
||||
children,
|
||||
}: SearchToggleButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex size-[22px] items-center justify-center rounded transition-colors',
|
||||
active
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={onClick}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{tooltip}</span>
|
||||
{shortcut && <span className="ml-1.5 text-[var(--color-text-muted)]">{shortcut}</span>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Match counter
|
||||
// =============================================================================
|
||||
|
||||
function countMatches(query: SearchQuery, state: EditorState): number {
|
||||
if (!query.valid || !query.search) return 0;
|
||||
|
||||
try {
|
||||
const cursor = query.getCursor(state);
|
||||
let count = 0;
|
||||
while (!cursor.next().done) {
|
||||
count++;
|
||||
if (count > MAX_MATCH_COUNT) return -1;
|
||||
}
|
||||
return count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EditorSearchPanelContent
|
||||
// =============================================================================
|
||||
|
||||
interface EditorSearchPanelContentProps {
|
||||
view: EditorView;
|
||||
initialSearch: string;
|
||||
initialReplace: string;
|
||||
initialCaseSensitive: boolean;
|
||||
initialRegexp: boolean;
|
||||
initialWholeWord: boolean;
|
||||
registerUpdateNotifier: (cb: () => void) => void;
|
||||
}
|
||||
|
||||
const EditorSearchPanelContent = ({
|
||||
view,
|
||||
initialSearch,
|
||||
initialReplace,
|
||||
initialCaseSensitive,
|
||||
initialRegexp,
|
||||
initialWholeWord,
|
||||
registerUpdateNotifier,
|
||||
}: EditorSearchPanelContentProps) => {
|
||||
const [searchText, setSearchText] = useState(initialSearch);
|
||||
const [replaceText, setReplaceText] = useState(initialReplace);
|
||||
const [caseSensitive, setCaseSensitive] = useState(initialCaseSensitive);
|
||||
const [useRegexp, setUseRegexp] = useState(initialRegexp);
|
||||
const [wholeWord, setWholeWord] = useState(initialWholeWord);
|
||||
const [showReplace, setShowReplace] = useState(false);
|
||||
const [updateTick, setUpdateTick] = useState(0);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus search input on mount
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Build query object (memoized)
|
||||
const query = useMemo(
|
||||
() =>
|
||||
new SearchQuery({
|
||||
search: searchText,
|
||||
replace: replaceText,
|
||||
caseSensitive,
|
||||
regexp: useRegexp,
|
||||
wholeWord,
|
||||
}),
|
||||
[searchText, replaceText, caseSensitive, useRegexp, wholeWord]
|
||||
);
|
||||
|
||||
// Dispatch search query to CodeMirror for highlighting
|
||||
useEffect(() => {
|
||||
view.dispatch({ effects: setSearchQuery.of(query) });
|
||||
}, [query, view]);
|
||||
|
||||
// Register for editor updates (doc changes → recount via updateTick)
|
||||
useEffect(() => {
|
||||
registerUpdateNotifier(() => setUpdateTick((t) => t + 1));
|
||||
}, [registerUpdateNotifier]);
|
||||
|
||||
// Match count — derived from query + document state
|
||||
// updateTick triggers recount on document changes (e.g. after replace)
|
||||
const matchCount = useMemo(
|
||||
() => countMatches(query, view.state),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateTick is a proxy dep for view.state changes
|
||||
[query, view, updateTick]
|
||||
);
|
||||
|
||||
// Navigation
|
||||
const handleFindNext = useCallback(() => {
|
||||
findNext(view);
|
||||
}, [view]);
|
||||
|
||||
const handleFindPrev = useCallback(() => {
|
||||
findPrevious(view);
|
||||
}, [view]);
|
||||
|
||||
// Replace
|
||||
const handleReplaceNext = useCallback(() => {
|
||||
replaceNext(view);
|
||||
}, [view]);
|
||||
|
||||
const handleReplaceAll = useCallback(() => {
|
||||
replaceAll(view);
|
||||
}, [view]);
|
||||
|
||||
// Close
|
||||
const handleClose = useCallback(() => {
|
||||
closeSearchPanel(view);
|
||||
view.focus();
|
||||
}, [view]);
|
||||
|
||||
// Keyboard handlers
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
findPrevious(view);
|
||||
} else {
|
||||
findNext(view);
|
||||
}
|
||||
}
|
||||
},
|
||||
[view, handleClose]
|
||||
);
|
||||
|
||||
const handleReplaceKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleReplaceNext();
|
||||
}
|
||||
},
|
||||
[handleClose, handleReplaceNext]
|
||||
);
|
||||
|
||||
// Match count display
|
||||
const matchCountText = searchText
|
||||
? matchCount === -1
|
||||
? `${MAX_MATCH_COUNT}+`
|
||||
: matchCount === 0
|
||||
? 'No results'
|
||||
: `${matchCount} found`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="flex flex-col gap-1 px-2 py-1.5">
|
||||
{/* Search row */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Toggle replace visibility */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[22px] w-5 items-center justify-center rounded text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setShowReplace((prev) => !prev)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showReplace ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Toggle Replace</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Search input */}
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
className="h-[26px] min-w-[180px] flex-1 rounded border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-xs"
|
||||
placeholder="Search"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{/* Toggle buttons */}
|
||||
<SearchToggleButton
|
||||
active={caseSensitive}
|
||||
onClick={() => setCaseSensitive((prev) => !prev)}
|
||||
tooltip="Match Case"
|
||||
>
|
||||
<CaseSensitive className="size-[14px]" />
|
||||
</SearchToggleButton>
|
||||
|
||||
<SearchToggleButton
|
||||
active={wholeWord}
|
||||
onClick={() => setWholeWord((prev) => !prev)}
|
||||
tooltip="Match Whole Word"
|
||||
>
|
||||
<WholeWord className="size-[14px]" />
|
||||
</SearchToggleButton>
|
||||
|
||||
<SearchToggleButton
|
||||
active={useRegexp}
|
||||
onClick={() => setUseRegexp((prev) => !prev)}
|
||||
tooltip="Use Regular Expression"
|
||||
>
|
||||
<Regex className="size-[14px]" />
|
||||
</SearchToggleButton>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="mx-0.5 h-4 w-px bg-[var(--color-border)]" />
|
||||
|
||||
{/* Match count */}
|
||||
{matchCountText && (
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-[60px] whitespace-nowrap text-center text-xs tabular-nums',
|
||||
matchCount === 0 && searchText ? 'text-red-400' : 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
>
|
||||
{matchCountText}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[22px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={handleFindPrev}
|
||||
disabled={matchCount === 0}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Previous Match <span className="text-[var(--color-text-muted)]">⇧Enter</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[22px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={handleFindNext}
|
||||
disabled={matchCount === 0}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArrowDown className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Next Match <span className="text-[var(--color-text-muted)]">Enter</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Close */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-[22px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={handleClose}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Close <span className="text-[var(--color-text-muted)]">Esc</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Replace row */}
|
||||
{showReplace && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Spacer to align with search input */}
|
||||
<div className="w-5 shrink-0" />
|
||||
|
||||
<Input
|
||||
className="h-[26px] min-w-[180px] flex-1 rounded border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-xs"
|
||||
placeholder="Replace"
|
||||
value={replaceText}
|
||||
onChange={(e) => setReplaceText(e.target.value)}
|
||||
onKeyDown={handleReplaceKeyDown}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-[22px] px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={handleReplaceNext}
|
||||
disabled={matchCount === 0}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Replace Next</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-[22px] px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={handleReplaceAll}
|
||||
disabled={matchCount === 0}
|
||||
tabIndex={-1}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Replace All</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Panel factory for CodeMirror
|
||||
// =============================================================================
|
||||
|
||||
export function createSearchPanel(view: EditorView): Panel {
|
||||
const dom = document.createElement('div');
|
||||
|
||||
const root = createRoot(dom);
|
||||
|
||||
// Get initial values
|
||||
const existingQuery = getSearchQuery(view.state);
|
||||
const sel = view.state.selection.main;
|
||||
const selText = sel.empty ? '' : view.state.sliceDoc(sel.from, sel.to);
|
||||
const initialSearch = selText && !selText.includes('\n') ? selText : existingQuery.search;
|
||||
|
||||
// Mutable ref for update notifications from CodeMirror
|
||||
let notifyUpdate: (() => void) | null = null;
|
||||
|
||||
root.render(
|
||||
<EditorSearchPanelContent
|
||||
view={view}
|
||||
initialSearch={initialSearch}
|
||||
initialReplace={existingQuery.replace}
|
||||
initialCaseSensitive={existingQuery.caseSensitive}
|
||||
initialRegexp={existingQuery.regexp}
|
||||
initialWholeWord={existingQuery.wholeWord}
|
||||
registerUpdateNotifier={(cb) => {
|
||||
notifyUpdate = cb;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
dom,
|
||||
top: true,
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged) {
|
||||
notifyUpdate?.();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
notifyUpdate = null;
|
||||
root.unmount();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Theme: panel container + search match highlighting
|
||||
// =============================================================================
|
||||
|
||||
export const editorSearchPanelTheme = EditorView.theme({
|
||||
'.cm-panels': {
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
},
|
||||
'.cm-panels-top': {
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
},
|
||||
'.cm-panels-bottom': {
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
},
|
||||
// Search match highlighting in editor content
|
||||
'.cm-searchMatch': {
|
||||
backgroundColor: 'var(--highlight-bg-inactive)',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
'.cm-searchMatch-selected': {
|
||||
backgroundColor: 'var(--highlight-bg) !important',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
* Uses onMouseDown preventDefault to avoid deselecting text in CM6.
|
||||
*/
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { ListTodo, MessageSquare } from 'lucide-react';
|
||||
|
||||
|
|
@ -92,16 +93,17 @@ interface MenuButtonProps {
|
|||
const MenuButton = ({ icon, label, onClick }: MenuButtonProps): React.ReactElement => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
onMouseDown={(e) => e.preventDefault()} // prevent CM6 selection loss
|
||||
className="rounded p-1.5 text-text-secondary transition-colors hover:bg-surface-raised hover:text-text"
|
||||
className="size-7 p-1.5 text-text-secondary"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
* the appropriate modifier symbols.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog';
|
||||
import { IS_MAC } from '@renderer/utils/platformKeys';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
|
|
@ -64,6 +64,13 @@ const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
|
|||
{ mac: '⌘ /', other: 'Ctrl+/', description: 'Toggle Comment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Markdown',
|
||||
shortcuts: [
|
||||
{ mac: '⌘ ⇧ M', other: 'Ctrl+Shift+M', description: 'Split Preview' },
|
||||
{ mac: '⌘ ⇧ V', other: 'Ctrl+Shift+V', description: 'Full Preview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }],
|
||||
|
|
@ -75,19 +82,6 @@ const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
|
|||
// =============================================================================
|
||||
|
||||
export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): React.ReactElement => {
|
||||
// Escape closes help (capture phase)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [onClose]);
|
||||
|
||||
// Resolve platform-specific keys once
|
||||
const resolvedGroups = useMemo(
|
||||
() =>
|
||||
|
|
@ -102,29 +96,11 @@ export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): Reac
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center" role="presentation">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shortcuts-dialog-title"
|
||||
className="relative z-10 w-[480px] rounded-lg border border-border-emphasis bg-surface p-6 shadow-2xl"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 id="shortcuts-dialog-title" className="text-sm font-semibold text-text">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="w-[480px] max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Keyboard Shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||
{resolvedGroups.map((group) => (
|
||||
|
|
@ -143,7 +119,7 @@ export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): Reac
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@
|
|||
* Status bar: cursor position, language, encoding, indent style, git branch.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
interface EditorStatusBarProps {
|
||||
line: number;
|
||||
|
|
@ -11,14 +15,19 @@ interface EditorStatusBarProps {
|
|||
language: string;
|
||||
}
|
||||
|
||||
export const EditorStatusBar = ({
|
||||
export const EditorStatusBar = React.memo(function EditorStatusBar({
|
||||
line,
|
||||
col,
|
||||
language,
|
||||
}: EditorStatusBarProps): React.ReactElement => {
|
||||
const gitBranch = useStore((s) => s.editorGitBranch);
|
||||
const isGitRepo = useStore((s) => s.editorIsGitRepo);
|
||||
const watcherEnabled = useStore((s) => s.editorWatcherEnabled);
|
||||
}: EditorStatusBarProps): React.ReactElement {
|
||||
const { gitBranch, isGitRepo, watcherEnabled } = useStore(
|
||||
useShallow((s) => ({
|
||||
gitBranch: s.editorGitBranch,
|
||||
isGitRepo: s.editorIsGitRepo,
|
||||
watcherEnabled: s.editorWatcherEnabled,
|
||||
}))
|
||||
);
|
||||
const toggleWatcher = useStore((s) => s.toggleWatcher);
|
||||
|
||||
return (
|
||||
<div className="flex h-6 shrink-0 items-center justify-between border-t border-border bg-surface-sidebar px-3 text-[11px] text-text-muted">
|
||||
|
|
@ -34,15 +43,30 @@ export const EditorStatusBar = ({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{watcherEnabled && (
|
||||
<span className="text-green-400" title="File watcher active">
|
||||
watching
|
||||
</span>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleWatcher(!watcherEnabled)}
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
||||
watcherEnabled
|
||||
? 'bg-green-500/15 text-green-400 hover:bg-green-500/20'
|
||||
: 'text-text-muted hover:bg-surface-raised hover:text-text-secondary'
|
||||
}`}
|
||||
aria-label={watcherEnabled ? 'Disable file watcher' : 'Enable file watcher'}
|
||||
aria-pressed={watcherEnabled}
|
||||
>
|
||||
{watcherEnabled ? 'watching' : 'watch'}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{watcherEnabled ? 'Disable external change watcher' : 'Watch for external changes'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span>{language}</span>
|
||||
<span>UTF-8</span>
|
||||
<span>Spaces: 2</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,31 @@
|
|||
/**
|
||||
* Tab bar for the project editor.
|
||||
* Shows open files as tabs with dirty indicator (dot) and close button.
|
||||
* Shows open files as tabs with dirty indicator (dot), close button,
|
||||
* right-click context menu (close others, close to left/right, close all),
|
||||
* and drag-and-drop reordering via @dnd-kit.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { X } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { getFileIcon } from './fileIcons';
|
||||
import { EditorTabContextMenu } from './EditorTabContextMenu';
|
||||
import { FileIcon } from './FileIcon';
|
||||
|
||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import type { EditorFileTab } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -27,99 +44,222 @@ interface EditorTabBarProps {
|
|||
export const EditorTabBar = ({
|
||||
onRequestCloseTab,
|
||||
}: EditorTabBarProps): React.ReactElement | null => {
|
||||
const tabs = useStore((s) => s.editorOpenTabs);
|
||||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
|
||||
const { tabs, activeTabId, modifiedFiles } = useStore(
|
||||
useShallow((s) => ({
|
||||
tabs: s.editorOpenTabs,
|
||||
activeTabId: s.editorActiveTabId,
|
||||
modifiedFiles: s.editorModifiedFiles,
|
||||
}))
|
||||
);
|
||||
const setActiveEditorTab = useStore((s) => s.setActiveEditorTab);
|
||||
const reorderEditorTabs = useStore((s) => s.reorderEditorTabs);
|
||||
const closeOtherEditorTabs = useStore((s) => s.closeOtherEditorTabs);
|
||||
const closeEditorTabsToLeft = useStore((s) => s.closeEditorTabsToLeft);
|
||||
const closeEditorTabsToRight = useStore((s) => s.closeEditorTabsToRight);
|
||||
const closeAllEditorTabs = useStore((s) => s.closeAllEditorTabs);
|
||||
|
||||
const [draggedTab, setDraggedTab] = useState<EditorFileTab | null>(null);
|
||||
|
||||
const tabIds = useMemo(() => tabs.map((t) => t.id), [tabs]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const tab = tabs.find((t) => t.id === event.active.id);
|
||||
setDraggedTab(tab ?? null);
|
||||
},
|
||||
[tabs]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setDraggedTab(null);
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
reorderEditorTabs(String(active.id), String(over.id));
|
||||
}
|
||||
},
|
||||
[reorderEditorTabs]
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setDraggedTab(null);
|
||||
}, []);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
|
||||
role="tablist"
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isModified={!!modifiedFiles[tab.filePath]}
|
||||
onActivate={() => setActiveEditorTab(tab.id)}
|
||||
onClose={() => onRequestCloseTab(tab.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
|
||||
role="tablist"
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab, index) => (
|
||||
<SortableEditorTab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
tabIndex={index}
|
||||
totalTabs={tabs.length}
|
||||
isActive={tab.id === activeTabId}
|
||||
isModified={!!modifiedFiles[tab.filePath]}
|
||||
onActivate={() => setActiveEditorTab(tab.id)}
|
||||
onRequestClose={onRequestCloseTab}
|
||||
onCloseOthers={closeOtherEditorTabs}
|
||||
onCloseToLeft={closeEditorTabsToLeft}
|
||||
onCloseToRight={closeEditorTabsToRight}
|
||||
onCloseAll={closeAllEditorTabs}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedTab && <EditorTabOverlay tab={draggedTab} />}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Tab item
|
||||
// Sortable tab item
|
||||
// =============================================================================
|
||||
|
||||
interface TabProps {
|
||||
interface SortableEditorTabProps {
|
||||
tab: EditorFileTab;
|
||||
tabIndex: number;
|
||||
totalTabs: number;
|
||||
isActive: boolean;
|
||||
isModified: boolean;
|
||||
onActivate: () => void;
|
||||
onClose: () => void;
|
||||
onRequestClose: (tabId: string) => void;
|
||||
onCloseOthers: (tabId: string) => void;
|
||||
onCloseToLeft: (tabId: string) => void;
|
||||
onCloseToRight: (tabId: string) => void;
|
||||
onCloseAll: () => void;
|
||||
}
|
||||
|
||||
const Tab = ({ tab, isActive, isModified, onActivate, onClose }: TabProps): React.ReactElement => {
|
||||
const SortableEditorTab = ({
|
||||
tab,
|
||||
tabIndex,
|
||||
totalTabs,
|
||||
isActive,
|
||||
isModified,
|
||||
onActivate,
|
||||
onRequestClose,
|
||||
onCloseOthers,
|
||||
onCloseToLeft,
|
||||
onCloseToRight,
|
||||
onCloseAll,
|
||||
}: SortableEditorTabProps): React.ReactElement => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: tab.id,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: isDragging ? 'none' : transition,
|
||||
opacity: isDragging ? 0.3 : 1,
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
onRequestClose(tab.id);
|
||||
};
|
||||
|
||||
const handleAuxClick = (e: React.MouseEvent) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
onRequestClose(tab.id);
|
||||
}
|
||||
};
|
||||
|
||||
const iconInfo = getFileIcon(tab.fileName);
|
||||
const FileIcon = iconInfo.icon;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
onAuxClick={handleAuxClick}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`group flex h-full shrink-0 items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
|
||||
isActive
|
||||
? 'bg-surface text-text'
|
||||
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{isModified && (
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-400"
|
||||
aria-label="Unsaved changes"
|
||||
/>
|
||||
)}
|
||||
<FileIcon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
|
||||
<span className="max-w-40 truncate">
|
||||
{tab.fileName}
|
||||
{tab.disambiguatedLabel && (
|
||||
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
onClick={handleClose}
|
||||
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
|
||||
role="button"
|
||||
aria-label={`Close ${tab.fileName}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
|
||||
</Tooltip>
|
||||
// Sortable wrapper — must be the outermost element so @dnd-kit controls its position.
|
||||
// ContextMenu.Trigger inside EditorTabContextMenu adds an extra <div>,
|
||||
// so the useSortable ref/transform CANNOT live on the inner <button>.
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex h-full shrink-0"
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading -- @dnd-kit useSortable requires prop spreading
|
||||
{...attributes}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading -- @dnd-kit useSortable requires prop spreading
|
||||
{...listeners}
|
||||
>
|
||||
<EditorTabContextMenu
|
||||
tabId={tab.id}
|
||||
tabIndex={tabIndex}
|
||||
totalTabs={totalTabs}
|
||||
onClose={onRequestClose}
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseToLeft={onCloseToLeft}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onCloseAll={onCloseAll}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
onAuxClick={handleAuxClick}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`group flex h-full shrink-0 cursor-grab items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
|
||||
isActive
|
||||
? 'bg-surface text-text'
|
||||
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{isModified && (
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-400"
|
||||
aria-label="Unsaved changes"
|
||||
/>
|
||||
)}
|
||||
<FileIcon fileName={tab.fileName} className="size-3.5" />
|
||||
<span className="max-w-40 truncate">
|
||||
{tab.fileName}
|
||||
{tab.disambiguatedLabel && (
|
||||
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
onClick={handleClose}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
|
||||
role="button"
|
||||
aria-label={`Close ${tab.fileName}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
|
||||
</Tooltip>
|
||||
</EditorTabContextMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Drag overlay (ghost shown while dragging)
|
||||
// =============================================================================
|
||||
|
||||
const EditorTabOverlay = ({ tab }: { tab: EditorFileTab }): React.ReactElement => (
|
||||
<div className="flex items-center gap-1.5 rounded border border-border-emphasis bg-surface-raised px-3 py-1 text-xs text-text shadow-lg">
|
||||
<FileIcon fileName={tab.fileName} className="size-3.5" />
|
||||
<span className="max-w-40 truncate">{tab.fileName}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
88
src/renderer/components/team/editor/EditorTabContextMenu.tsx
Normal file
88
src/renderer/components/team/editor/EditorTabContextMenu.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Context menu for editor tabs.
|
||||
* Supports: close, close others, close to left/right, close all.
|
||||
*/
|
||||
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
|
||||
interface EditorTabContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
tabId: string;
|
||||
tabIndex: number;
|
||||
totalTabs: number;
|
||||
onClose: (tabId: string) => void;
|
||||
onCloseOthers: (tabId: string) => void;
|
||||
onCloseToLeft: (tabId: string) => void;
|
||||
onCloseToRight: (tabId: string) => void;
|
||||
onCloseAll: () => void;
|
||||
}
|
||||
|
||||
export const EditorTabContextMenu = ({
|
||||
children,
|
||||
tabId,
|
||||
tabIndex,
|
||||
totalTabs,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseToLeft,
|
||||
onCloseToRight,
|
||||
onCloseAll,
|
||||
}: EditorTabContextMenuProps): React.ReactElement => {
|
||||
const hasLeft = tabIndex > 0;
|
||||
const hasRight = tabIndex < totalTabs - 1;
|
||||
const hasOthers = totalTabs > 1;
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<div className="flex h-full">{children}</div>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content className="z-50 min-w-[180px] rounded-md border border-border-emphasis bg-surface-overlay p-1 shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95">
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => onClose(tabId)}
|
||||
>
|
||||
Close
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center 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-40"
|
||||
disabled={!hasOthers}
|
||||
onSelect={() => onCloseOthers(tabId)}
|
||||
>
|
||||
Close Others
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Separator className="my-1 h-px bg-border" />
|
||||
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center 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-40"
|
||||
disabled={!hasLeft}
|
||||
onSelect={() => onCloseToLeft(tabId)}
|
||||
>
|
||||
Close Tabs to the Left
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center 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-40"
|
||||
disabled={!hasRight}
|
||||
onSelect={() => onCloseToRight(tabId)}
|
||||
>
|
||||
Close Tabs to the Right
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Separator className="my-1 h-px bg-border" />
|
||||
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={onCloseAll}
|
||||
>
|
||||
Close All
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
|
@ -2,23 +2,49 @@
|
|||
* Toolbar with Save, Undo, Redo buttons.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { redo, undo } from '@codemirror/commands';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||
import { Redo2, Save, Undo2, WrapText } from 'lucide-react';
|
||||
import { Columns2, Eye, Redo2, Save, Undo2, WrapText } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type MdPreviewMode = 'off' | 'split' | 'preview';
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const EditorToolbar = (): React.ReactElement | null => {
|
||||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
|
||||
const saving = useStore((s) => s.editorSaving);
|
||||
interface EditorToolbarProps {
|
||||
isMarkdown?: boolean;
|
||||
mdPreviewMode?: MdPreviewMode;
|
||||
onToggleSplit?: () => void;
|
||||
onToggleFullPreview?: () => void;
|
||||
}
|
||||
|
||||
export const EditorToolbar = ({
|
||||
isMarkdown = false,
|
||||
mdPreviewMode = 'off',
|
||||
onToggleSplit,
|
||||
onToggleFullPreview,
|
||||
}: EditorToolbarProps): React.ReactElement | null => {
|
||||
const { activeTabId, modifiedFiles, saving, lineWrap } = useStore(
|
||||
useShallow((s) => ({
|
||||
activeTabId: s.editorActiveTabId,
|
||||
modifiedFiles: s.editorModifiedFiles,
|
||||
saving: s.editorSaving,
|
||||
lineWrap: s.editorLineWrap,
|
||||
}))
|
||||
);
|
||||
const saveFile = useStore((s) => s.saveFile);
|
||||
const lineWrap = useStore((s) => s.editorLineWrap);
|
||||
const toggleLineWrap = useStore((s) => s.toggleLineWrap);
|
||||
|
||||
if (!activeTabId) return null;
|
||||
|
|
@ -69,6 +95,25 @@ export const EditorToolbar = (): React.ReactElement | null => {
|
|||
onClick={toggleLineWrap}
|
||||
active={lineWrap}
|
||||
/>
|
||||
{isMarkdown && (
|
||||
<>
|
||||
<div className="mx-1 h-4 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
icon={<Columns2 className="size-3.5" />}
|
||||
label={mdPreviewMode === 'split' ? 'Close split preview' : 'Split preview'}
|
||||
shortcut={shortcutLabel('⌘ ⇧ M', 'Ctrl+Shift+M')}
|
||||
onClick={onToggleSplit ?? (() => {})}
|
||||
active={mdPreviewMode === 'split'}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Eye className="size-3.5" />}
|
||||
label={mdPreviewMode === 'preview' ? 'Close preview' : 'Full preview'}
|
||||
shortcut={shortcutLabel('⌘ ⇧ V', 'Ctrl+Shift+V')}
|
||||
onClick={onToggleFullPreview ?? (() => {})}
|
||||
active={mdPreviewMode === 'preview'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -86,30 +131,33 @@ interface ToolbarButtonProps {
|
|||
active?: boolean;
|
||||
}
|
||||
|
||||
const ToolbarButton = ({
|
||||
const ToolbarButton = React.memo(function ToolbarButton({
|
||||
icon,
|
||||
label,
|
||||
shortcut,
|
||||
onClick,
|
||||
disabled = false,
|
||||
active = false,
|
||||
}: ToolbarButtonProps): React.ReactElement => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors hover:bg-surface-raised hover:text-text disabled:opacity-40 disabled:hover:bg-transparent ${
|
||||
active ? 'bg-surface-raised text-text' : 'text-text-muted'
|
||||
}`}
|
||||
aria-label={`${label} (${shortcut})`}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{label} ({shortcut})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}: ToolbarButtonProps): React.ReactElement {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`h-auto gap-1 px-1.5 py-0.5 text-xs ${
|
||||
active ? 'bg-surface-raised text-text' : 'text-text-muted'
|
||||
}`}
|
||||
aria-label={`${label} (${shortcut})`}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{label} ({shortcut})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
66
src/renderer/components/team/editor/FileIcon.tsx
Normal file
66
src/renderer/components/team/editor/FileIcon.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* FileIcon — renders a file-type icon.
|
||||
*
|
||||
* For programming languages/frameworks: uses Devicon CDN SVG (real colorful logos).
|
||||
* For generic types (images, fonts, configs): uses lucide-react icons with tinted color.
|
||||
* Falls back to lucide if the Devicon image fails to load.
|
||||
*
|
||||
* Applies a subtle glow (drop-shadow) in dark mode so dark-colored icons
|
||||
* remain visible against dark backgrounds (e.g. Go, Rust, C).
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
import { getDeviconUrl, getFileIcon } from './fileIcons';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface FileIconProps {
|
||||
/** File name (e.g. "index.ts", "Dockerfile", "logo.png") */
|
||||
fileName: string;
|
||||
/** Tailwind size class (e.g. "size-3.5", "size-4"). Defaults to "size-3.5" */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Track slugs that failed to load so we don't retry them across mounts
|
||||
const failedSlugs = new Set<string>();
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const FileIcon = memo(({ fileName, className = 'size-3.5' }: FileIconProps) => {
|
||||
const info = getFileIcon(fileName);
|
||||
const slug = info.deviconSlug;
|
||||
const canUseDevicon = slug != null && !failedSlugs.has(slug);
|
||||
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
if (slug) failedSlugs.add(slug);
|
||||
setImgFailed(true);
|
||||
}, [slug]);
|
||||
|
||||
if (canUseDevicon && !imgFailed) {
|
||||
return (
|
||||
<img
|
||||
src={getDeviconUrl(slug)}
|
||||
className={cn('file-icon-glow shrink-0', className)}
|
||||
onError={handleError}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to lucide icon
|
||||
const Icon = info.icon;
|
||||
return <Icon className={cn('shrink-0', className)} style={{ color: info.color }} />;
|
||||
});
|
||||
|
||||
FileIcon.displayName = 'FileIcon';
|
||||
|
|
@ -10,6 +10,8 @@
|
|||
* - R (renamed) — cyan
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { GitFileStatusType } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -33,11 +35,13 @@ interface GitStatusBadgeProps {
|
|||
status: GitFileStatusType;
|
||||
}
|
||||
|
||||
export const GitStatusBadge = ({ status }: GitStatusBadgeProps): React.ReactElement => {
|
||||
export const GitStatusBadge = React.memo(function GitStatusBadge({
|
||||
status,
|
||||
}: GitStatusBadgeProps): React.ReactElement {
|
||||
const config = STATUS_CONFIG[status];
|
||||
return (
|
||||
<span className={`ml-auto shrink-0 text-[10px] leading-none ${config.color}`} title={status}>
|
||||
{config.letter}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
};
|
||||
57
src/renderer/components/team/editor/MarkdownPreviewPane.tsx
Normal file
57
src/renderer/components/team/editor/MarkdownPreviewPane.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Scrollable markdown preview pane for the editor split view.
|
||||
*
|
||||
* Wraps MarkdownViewer in a scrollable container with ref access
|
||||
* for external scroll synchronization (code ↔ preview).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface MarkdownPreviewPaneProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
onScroll?: () => void;
|
||||
/** Base directory for resolving relative image/link URLs */
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const MarkdownPreviewPane = React.memo(function MarkdownPreviewPane({
|
||||
content,
|
||||
className = '',
|
||||
scrollRef,
|
||||
onScroll,
|
||||
baseDir,
|
||||
}: MarkdownPreviewPaneProps): React.ReactElement {
|
||||
// Callback ref to wire scrollRef (RefObject<T | null>) to the div
|
||||
const internalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const setRef = React.useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
internalRef.current = el;
|
||||
if (scrollRef && 'current' in scrollRef) {
|
||||
// Forward ref — the mutable cast is the standard pattern for forwarding refs
|
||||
const mutableRef = scrollRef as React.MutableRefObject<HTMLDivElement | null>;
|
||||
mutableRef.current = el;
|
||||
}
|
||||
},
|
||||
[scrollRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={setRef} className={`h-full overflow-y-auto ${className}`} onScroll={onScroll}>
|
||||
<div className="p-4">
|
||||
<MarkdownViewer content={content} bare maxHeight="" baseDir={baseDir} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
128
src/renderer/components/team/editor/MarkdownSplitView.tsx
Normal file
128
src/renderer/components/team/editor/MarkdownSplitView.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Right-side panel for markdown split/preview mode.
|
||||
*
|
||||
* In split mode: renders a drag-resizable handle + MarkdownPreviewPane.
|
||||
* In preview mode: renders MarkdownPreviewPane at full width (no handle).
|
||||
*
|
||||
* CodeMirrorEditor is NOT rendered here — it stays in ProjectEditorOverlay
|
||||
* and is controlled via CSS display/width.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
|
||||
|
||||
import { MarkdownPreviewPane } from './MarkdownPreviewPane';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface MarkdownSplitViewProps {
|
||||
content: string;
|
||||
mode: 'split' | 'preview';
|
||||
splitRatio: number;
|
||||
onSplitRatioChange: (ratio: number) => void;
|
||||
/** Key that changes when the EditorView changes (e.g. activeTabId) — triggers scroll re-attach */
|
||||
viewKey?: string | null;
|
||||
/** Base directory for resolving relative image/link URLs */
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const MIN_RATIO = 0.2;
|
||||
const MAX_RATIO = 0.8;
|
||||
const HANDLE_WIDTH = 4; // px
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const MarkdownSplitView = React.memo(function MarkdownSplitView({
|
||||
content,
|
||||
mode,
|
||||
splitRatio,
|
||||
onSplitRatioChange,
|
||||
viewKey,
|
||||
baseDir,
|
||||
}: MarkdownSplitViewProps): React.ReactElement {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll sync auto-manages its own listener lifecycle via viewKey
|
||||
const scrollSync = useMarkdownScrollSync(mode === 'split', viewKey);
|
||||
|
||||
// --- Resize drag logic ---
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const parent = containerRef.current?.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const relativeX = e.clientX - parentRect.left;
|
||||
const newRatio = Math.min(MAX_RATIO, Math.max(MIN_RATIO, relativeX / parentRect.width));
|
||||
onSplitRatioChange(newRatio);
|
||||
},
|
||||
[onSplitRatioChange]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isResizing, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
};
|
||||
|
||||
// --- Preview width ---
|
||||
|
||||
const previewWidth =
|
||||
mode === 'preview' ? '100%' : `calc(${(1 - splitRatio) * 100}% - ${HANDLE_WIDTH}px)`;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full" style={{ width: previewWidth }}>
|
||||
{/* Resize handle — only in split mode */}
|
||||
{mode === 'split' && (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- resize handle
|
||||
<div
|
||||
className={`shrink-0 cursor-col-resize border-x border-border transition-colors ${
|
||||
isResizing ? 'bg-blue-500/50' : 'hover:bg-blue-500/30'
|
||||
}`}
|
||||
style={{ width: HANDLE_WIDTH }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Preview pane */}
|
||||
<div className="flex-1 overflow-hidden bg-surface">
|
||||
<MarkdownPreviewPane
|
||||
content={content}
|
||||
scrollRef={scrollSync.previewScrollRef}
|
||||
onScroll={scrollSync.handlePreviewScroll}
|
||||
baseDir={baseDir}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
/**
|
||||
* Inline input for creating a new file or directory in the file tree.
|
||||
*
|
||||
* Auto-focuses, validates on the client side, submits on Enter, cancels on Escape/blur.
|
||||
* Auto-focuses, validates on the client side, submits on Enter, cancels on Escape.
|
||||
* Uses click-outside detection instead of onBlur for dismissal — onBlur is
|
||||
* unreliable when the input lives inside a virtualizer + DnD context + Radix
|
||||
* context menu (all of which can steal focus transiently).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
|
@ -48,12 +51,33 @@ export const NewFileDialog = ({
|
|||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus input after Radix context menu finishes its focus restoration
|
||||
useEffect(() => {
|
||||
// Auto-focus on mount
|
||||
inputRef.current?.focus();
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Click-outside → cancel (replaces unreliable onBlur)
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (e: PointerEvent): void => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
// Delay listener registration so the context menu close click isn't caught
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
}, 150);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
};
|
||||
}, [onCancel]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
const validationError = validateName(trimmed);
|
||||
|
|
@ -73,6 +97,7 @@ export const NewFileDialog = ({
|
|||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
e.stopPropagation();
|
||||
},
|
||||
[handleSubmit, onCancel]
|
||||
);
|
||||
|
|
@ -85,7 +110,7 @@ export const NewFileDialog = ({
|
|||
const Icon = type === 'file' ? FilePlus : FolderPlus;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-2 py-1">
|
||||
<div ref={containerRef} className="flex flex-col px-2 py-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="size-3.5 shrink-0 text-text-muted" />
|
||||
<input
|
||||
|
|
@ -94,7 +119,7 @@ export const NewFileDialog = ({
|
|||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={onCancel}
|
||||
onBlur={() => requestAnimationFrame(() => inputRef.current?.focus())}
|
||||
placeholder={type === 'file' ? 'File name...' : 'Folder name...'}
|
||||
className="min-w-0 flex-1 rounded border border-border-emphasis bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-blue-500"
|
||||
aria-label={type === 'file' ? 'New file name' : 'New folder name'}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,19 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardShortcuts';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
|
||||
import { buildFileAction, buildSelectionAction } from '@renderer/utils/buildSelectionAction';
|
||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -22,6 +31,7 @@ import {
|
|||
RotateCcw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||
import { EditorBinaryState } from './EditorBinaryState';
|
||||
|
|
@ -34,9 +44,12 @@ import { EditorShortcutsHelp } from './EditorShortcutsHelp';
|
|||
import { EditorStatusBar } from './EditorStatusBar';
|
||||
import { EditorTabBar } from './EditorTabBar';
|
||||
import { EditorToolbar } from './EditorToolbar';
|
||||
import { GoToLineDialog } from './GoToLineDialog';
|
||||
import { MarkdownSplitView } from './MarkdownSplitView';
|
||||
import { QuickOpenDialog } from './QuickOpenDialog';
|
||||
import { SearchInFilesPanel } from './SearchInFilesPanel';
|
||||
|
||||
import type { MdPreviewMode } from './EditorToolbar';
|
||||
import type {
|
||||
EditorSelectionAction,
|
||||
EditorSelectionInfo,
|
||||
|
|
@ -63,23 +76,29 @@ export const ProjectEditorOverlay = ({
|
|||
onClose,
|
||||
onEditorAction,
|
||||
}: ProjectEditorOverlayProps): React.ReactElement => {
|
||||
// Data selectors — grouped with useShallow to prevent unnecessary re-renders
|
||||
const { activeTabId, openTabs, modifiedFiles, saveErrors, externalChanges, conflictFile } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
activeTabId: s.editorActiveTabId,
|
||||
openTabs: s.editorOpenTabs,
|
||||
modifiedFiles: s.editorModifiedFiles,
|
||||
saveErrors: s.editorSaveError,
|
||||
externalChanges: s.editorExternalChanges,
|
||||
conflictFile: s.editorConflictFile,
|
||||
}))
|
||||
);
|
||||
|
||||
// Actions — stable references in Zustand, no grouping needed
|
||||
const openEditor = useStore((s) => s.openEditor);
|
||||
const closeEditor = useStore((s) => s.closeEditor);
|
||||
const openFile = useStore((s) => s.openFile);
|
||||
const closeEditorTab = useStore((s) => s.closeEditorTab);
|
||||
const saveFile = useStore((s) => s.saveFile);
|
||||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||
const openTabs = useStore((s) => s.editorOpenTabs);
|
||||
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
|
||||
const saveErrors = useStore((s) => s.editorSaveError);
|
||||
const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges);
|
||||
const saveAllFiles = useStore((s) => s.saveAllFiles);
|
||||
const discardChanges = useStore((s) => s.discardChanges);
|
||||
|
||||
// Iter-5: git, watcher, conflict
|
||||
const externalChanges = useStore((s) => s.editorExternalChanges);
|
||||
const clearExternalChange = useStore((s) => s.clearExternalChange);
|
||||
const conflictFile = useStore((s) => s.editorConflictFile);
|
||||
const forceOverwrite = useStore((s) => s.forceOverwrite);
|
||||
const resolveConflict = useStore((s) => s.resolveConflict);
|
||||
const setFileMtime = useStore((s) => s.setFileMtime);
|
||||
|
|
@ -104,9 +123,22 @@ export const ProjectEditorOverlay = ({
|
|||
const editorContentRef = useRef<HTMLDivElement>(null);
|
||||
const [containerRect, setContainerRect] = useState<DOMRect>(() => new DOMRect());
|
||||
|
||||
// Markdown preview state
|
||||
const [mdPreviewMode, setMdPreviewMode] = useState<MdPreviewMode>('off');
|
||||
const [liveContent, setLiveContent] = useState('');
|
||||
const [splitRatio, setSplitRatio] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('editor:mdSplitRatio');
|
||||
return stored ? Math.max(0.2, Math.min(0.8, Number(stored))) : 0.5;
|
||||
} catch {
|
||||
return 0.5;
|
||||
}
|
||||
});
|
||||
|
||||
// Iter-4: New state
|
||||
const [quickOpenVisible, setQuickOpenVisible] = useState(false);
|
||||
const [searchPanelVisible, setSearchPanelVisible] = useState(false);
|
||||
const [goToLineVisible, setGoToLineVisible] = useState(false);
|
||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||
const [sidebarVisible, setSidebarVisibleRaw] = useState(() => {
|
||||
try {
|
||||
|
|
@ -123,6 +155,48 @@ export const ProjectEditorOverlay = ({
|
|||
|
||||
// Active tab metadata
|
||||
const activeTab = openTabs.find((t) => t.id === activeTabId) ?? null;
|
||||
const isMarkdown = activeTab?.language === 'Markdown';
|
||||
|
||||
// Auto-enable split preview for markdown tabs, reset for non-markdown
|
||||
useEffect(() => {
|
||||
if (isMarkdown) {
|
||||
setMdPreviewMode((m) => (m === 'off' ? 'split' : m));
|
||||
} else {
|
||||
setMdPreviewMode('off');
|
||||
}
|
||||
}, [isMarkdown, activeTabId]);
|
||||
|
||||
// Persist split ratio
|
||||
const handleSplitRatioChange = useCallback((ratio: number) => {
|
||||
setSplitRatio(ratio);
|
||||
try {
|
||||
localStorage.setItem('editor:mdSplitRatio', String(ratio));
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLiveContent = useCallback((content: string) => {
|
||||
setLiveContent(content);
|
||||
}, []);
|
||||
|
||||
const toggleMdSplit = useCallback(() => {
|
||||
setMdPreviewMode((m) => (m === 'split' ? 'off' : 'split'));
|
||||
}, []);
|
||||
|
||||
const toggleMdPreview = useCallback(() => {
|
||||
setMdPreviewMode((m) => (m === 'preview' ? 'off' : 'preview'));
|
||||
}, []);
|
||||
|
||||
// Initialize live content when entering preview mode or switching files
|
||||
useEffect(() => {
|
||||
if (mdPreviewMode !== 'off' && fileContent?.content) {
|
||||
setLiveContent(fileContent.content);
|
||||
}
|
||||
}, [mdPreviewMode, fileContent?.content]);
|
||||
|
||||
// Content for preview: use live content when available, fallback to file content
|
||||
const previewContent = liveContent || fileContent?.content || '';
|
||||
|
||||
const loadFileContent = useCallback(
|
||||
async (filePath: string) => {
|
||||
|
|
@ -131,13 +205,19 @@ export const ProjectEditorOverlay = ({
|
|||
setFileContent(null);
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
let promise = pendingReads.current.get(filePath);
|
||||
const wasCached = !!promise;
|
||||
if (!promise) {
|
||||
promise = window.electronAPI.editor.readFile(filePath);
|
||||
pendingReads.current.set(filePath, promise);
|
||||
void promise.finally(() => pendingReads.current.delete(filePath));
|
||||
}
|
||||
const result = await promise;
|
||||
const ipcMs = performance.now() - t0;
|
||||
console.debug(
|
||||
`[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${filePath.split('/').pop() ?? ''}`
|
||||
);
|
||||
setFileContent(result);
|
||||
|
||||
// Track baseline mtime for conflict detection
|
||||
|
|
@ -157,6 +237,9 @@ export const ProjectEditorOverlay = ({
|
|||
// Active tab save error
|
||||
const activeSaveError = activeTabId ? (saveErrors[activeTabId] ?? null) : null;
|
||||
|
||||
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
|
||||
const revealAndOpenFile = useStore((s) => s.revealAndOpenFile);
|
||||
|
||||
// Initialize editor on mount
|
||||
useEffect(() => {
|
||||
void openEditor(projectPath);
|
||||
|
|
@ -165,6 +248,18 @@ export const ProjectEditorOverlay = ({
|
|||
};
|
||||
}, [projectPath, openEditor, closeEditor]);
|
||||
|
||||
// Process pending file reveal after editor initializes.
|
||||
// Guard: wait until the file tree is actually loaded (not null) and not loading.
|
||||
// Without the fileTree check, the effect fires on mount when fileTreeLoading is
|
||||
// still at its initial `false` value — before openEditor sets it to `true`.
|
||||
const fileTreeLoading = useStore((s) => s.editorFileTreeLoading);
|
||||
const fileTreeLoaded = useStore((s) => s.editorFileTree !== null);
|
||||
useEffect(() => {
|
||||
if (pendingRevealFile && !fileTreeLoading && fileTreeLoaded) {
|
||||
void revealAndOpenFile(pendingRevealFile);
|
||||
}
|
||||
}, [pendingRevealFile, fileTreeLoading, fileTreeLoaded, revealAndOpenFile]);
|
||||
|
||||
// Keep container rect fresh for selection menu positioning (resize, sidebar toggle)
|
||||
useEffect(() => {
|
||||
const el = editorContentRef.current;
|
||||
|
|
@ -180,6 +275,9 @@ export const ProjectEditorOverlay = ({
|
|||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Skip if another handler already consumed this Escape
|
||||
// (e.g. CodeMirror search panel close, or React search input onKeyDown)
|
||||
if (e.defaultPrevented) return;
|
||||
// Don't close overlay if a dialog is open — dialog handles its own Escape
|
||||
if (quickOpenVisible || searchPanelVisible || shortcutsHelpVisible) return;
|
||||
if (showConfirmClose || confirmCloseTabId) return;
|
||||
|
|
@ -381,6 +479,10 @@ export const ProjectEditorOverlay = ({
|
|||
setSearchPanelVisible((v) => !v);
|
||||
}, []);
|
||||
|
||||
const toggleGoToLine = useCallback(() => {
|
||||
setGoToLineVisible((v) => !v);
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarVisibleRaw((v) => {
|
||||
const next = !v;
|
||||
|
|
@ -410,8 +512,11 @@ export const ProjectEditorOverlay = ({
|
|||
useEditorKeyboardShortcuts({
|
||||
onToggleQuickOpen: toggleQuickOpen,
|
||||
onToggleSearchPanel: toggleSearchPanel,
|
||||
onToggleGoToLine: toggleGoToLine,
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onClose: handleCloseRequest,
|
||||
onToggleMdSplit: isMarkdown ? toggleMdSplit : undefined,
|
||||
onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined,
|
||||
});
|
||||
|
||||
const projectName = projectPath.split('/').pop() ?? projectPath;
|
||||
|
|
@ -437,35 +542,46 @@ export const ProjectEditorOverlay = ({
|
|||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-text-muted"
|
||||
onClick={handleManualRefresh}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Refresh (F5)"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Refresh git status (F5)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-text-muted"
|
||||
onClick={() => setShortcutsHelpVisible(true)}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Keyboard shortcuts</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={handleCloseRequest}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close editor"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-text-muted"
|
||||
onClick={handleCloseRequest}
|
||||
aria-label="Close editor"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Close editor (Esc)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -491,13 +607,15 @@ export const ProjectEditorOverlay = ({
|
|||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-text-muted"
|
||||
onClick={toggleSidebar}
|
||||
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Hide sidebar"
|
||||
>
|
||||
<PanelLeftClose className="size-3.5" />
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Hide sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
|
||||
|
|
@ -505,7 +623,22 @@ export const ProjectEditorOverlay = ({
|
|||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<EditorFileTree selectedFilePath={activeTabId} onFileSelect={handleFileSelect} />
|
||||
<EditorFileTree
|
||||
selectedFilePath={activeTabId}
|
||||
onFileSelect={handleFileSelect}
|
||||
onCreateTask={
|
||||
onEditorAction
|
||||
? (filePath: string) =>
|
||||
onEditorAction(buildFileAction('createTask', filePath, projectPath))
|
||||
: undefined
|
||||
}
|
||||
onSendMessage={
|
||||
onEditorAction
|
||||
? (filePath: string) =>
|
||||
onEditorAction(buildFileAction('sendMessage', filePath, projectPath))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -514,13 +647,14 @@ export const ProjectEditorOverlay = ({
|
|||
{!sidebarVisible && !searchPanelVisible && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-full w-6 shrink-0 items-start justify-center rounded-none border-r border-border bg-surface-sidebar pt-2 text-text-muted"
|
||||
onClick={toggleSidebar}
|
||||
className="flex h-full w-6 shrink-0 items-start justify-center border-r border-border bg-surface-sidebar pt-2 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Show sidebar"
|
||||
>
|
||||
<PanelLeftOpen className="size-3.5" />
|
||||
</button>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Show sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
|
||||
|
|
@ -534,25 +668,34 @@ export const ProjectEditorOverlay = ({
|
|||
<EditorTabBar onRequestCloseTab={handleRequestCloseTab} />
|
||||
|
||||
{/* Toolbar */}
|
||||
<EditorToolbar />
|
||||
<EditorToolbar
|
||||
isMarkdown={isMarkdown}
|
||||
mdPreviewMode={mdPreviewMode}
|
||||
onToggleSplit={toggleMdSplit}
|
||||
onToggleFullPreview={toggleMdPreview}
|
||||
/>
|
||||
|
||||
{/* Draft recovery banner */}
|
||||
{draftRecoveredFile && activeTabId === draftRecoveredFile && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-300">
|
||||
<RotateCcw className="size-3.5 shrink-0" />
|
||||
<span>Recovered unsaved changes from a previous session.</span>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-auto px-2 py-0.5"
|
||||
onClick={handleDismissDraftBanner}
|
||||
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-auto px-2 py-0.5"
|
||||
onClick={handleDiscardDraft}
|
||||
className="rounded px-2 py-0.5 text-red-400 transition-colors hover:bg-red-400/10"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -561,12 +704,14 @@ export const ProjectEditorOverlay = ({
|
|||
<div className="flex shrink-0 items-center gap-2 border-b border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs text-red-300">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<span className="truncate">Save failed: {activeSaveError}</span>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-auto shrink-0 px-2 py-0.5"
|
||||
onClick={() => activeTabId && void saveFile(activeTabId)}
|
||||
className="ml-auto shrink-0 rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -580,26 +725,32 @@ export const ProjectEditorOverlay = ({
|
|||
: 'File changed on disk.'}
|
||||
</span>
|
||||
{externalChanges[activeTabId] === 'delete' ? (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-auto px-2 py-0.5"
|
||||
onClick={() => closeEditorTab(activeTabId)}
|
||||
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Close tab
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-auto px-2 py-0.5"
|
||||
onClick={handleReloadExternalChange}
|
||||
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-2 py-0.5"
|
||||
onClick={handleKeepMine}
|
||||
className="rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Keep mine
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -620,18 +771,42 @@ export const ProjectEditorOverlay = ({
|
|||
)}
|
||||
|
||||
{fileContent && !fileContent.isBinary && activeTabId && (
|
||||
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
|
||||
<CodeMirrorEditor
|
||||
key={`${activeTabId}-${editorResetKey}`}
|
||||
filePath={activeTabId}
|
||||
content={fileContent.content}
|
||||
fileName={activeTabId.split('/').pop() ?? 'file'}
|
||||
mtimeMs={fileContent.mtimeMs}
|
||||
onCursorChange={handleCursorChange}
|
||||
onDraftRecovered={handleDraftRecovered}
|
||||
onSelectionChange={setSelectionInfo}
|
||||
/>
|
||||
</EditorErrorBoundary>
|
||||
<div className="flex h-full">
|
||||
{/* Code editor — always mounted, hidden via display:none in preview mode */}
|
||||
<div
|
||||
className="h-full overflow-hidden"
|
||||
style={{
|
||||
display: mdPreviewMode === 'preview' ? 'none' : 'block',
|
||||
width: mdPreviewMode === 'split' ? `${splitRatio * 100}%` : '100%',
|
||||
}}
|
||||
>
|
||||
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
|
||||
<CodeMirrorEditor
|
||||
key={`${activeTabId}-${editorResetKey}`}
|
||||
filePath={activeTabId}
|
||||
content={fileContent.content}
|
||||
fileName={activeTabId.split('/').pop() ?? 'file'}
|
||||
mtimeMs={fileContent.mtimeMs}
|
||||
onCursorChange={handleCursorChange}
|
||||
onDraftRecovered={handleDraftRecovered}
|
||||
onSelectionChange={setSelectionInfo}
|
||||
onDocChange={mdPreviewMode !== 'off' ? handleLiveContent : undefined}
|
||||
/>
|
||||
</EditorErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Resize handle + Preview pane */}
|
||||
{mdPreviewMode !== 'off' && (
|
||||
<MarkdownSplitView
|
||||
content={previewContent}
|
||||
mode={mdPreviewMode}
|
||||
splitRatio={splitRatio}
|
||||
onSplitRatioChange={handleSplitRatioChange}
|
||||
viewKey={activeTabId}
|
||||
baseDir={activeTabId?.substring(0, activeTabId.lastIndexOf('/'))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fileLoading && !fileError && !fileContent && !activeTabId && <EditorEmptyState />}
|
||||
|
|
@ -668,101 +843,80 @@ export const ProjectEditorOverlay = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Go to Line dialog */}
|
||||
{goToLineVisible && <GoToLineDialog onClose={() => setGoToLineVisible(false)} />}
|
||||
|
||||
{/* Shortcuts help modal */}
|
||||
{shortcutsHelpVisible && (
|
||||
<EditorShortcutsHelp onClose={() => setShortcutsHelpVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Unsaved changes confirmation dialog — overlay close */}
|
||||
{showConfirmClose && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Unsaved Changes</h3>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
<Dialog open={showConfirmClose} onOpenChange={(open) => !open && handleCancelClose()}>
|
||||
<DialogContent className="w-96 max-w-96">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have unsaved changes. What would you like to do?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCancelClose}
|
||||
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscardAndClose}
|
||||
className="rounded px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-400/10"
|
||||
>
|
||||
Discard & Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveAndClose()}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
Save All & Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancelClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDiscardAndClose}>
|
||||
Discard & Close
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleSaveAndClose()}>
|
||||
Save All & Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Save conflict dialog */}
|
||||
{conflictFile && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Save Conflict</h3>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
<Dialog open={!!conflictFile} onOpenChange={(open) => !open && handleCancelConflict()}>
|
||||
<DialogContent className="w-96 max-w-96">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Save Conflict</DialogTitle>
|
||||
<DialogDescription>
|
||||
The file has been modified externally since you opened it. Overwrite with your
|
||||
changes?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCancelConflict}
|
||||
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleForceOverwrite}
|
||||
className="rounded bg-orange-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-orange-500"
|
||||
>
|
||||
Overwrite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancelConflict}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleForceOverwrite}>
|
||||
Overwrite
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Unsaved changes confirmation dialog — single tab close */}
|
||||
{confirmCloseTabId && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Unsaved Changes</h3>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
<Dialog open={!!confirmCloseTabId} onOpenChange={(open) => !open && handleCancelCloseTab()}>
|
||||
<DialogContent className="w-96 max-w-96">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
This file has unsaved changes. What would you like to do?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCancelCloseTab}
|
||||
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscardAndCloseTab}
|
||||
className="rounded px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-400/10"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveAndCloseTab()}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancelCloseTab}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDiscardAndCloseTab}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleSaveAndCloseTab()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -37,10 +37,17 @@ export const QuickOpenDialog = ({
|
|||
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Reset loading state when projectPath changes (React-recommended
|
||||
// "adjusting state when props change" pattern without effects or refs)
|
||||
const [prevProjectPath, setPrevProjectPath] = useState(projectPath);
|
||||
if (prevProjectPath !== projectPath) {
|
||||
setPrevProjectPath(projectPath);
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
// Load all project files on mount via backend API
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
window.electronAPI.editor
|
||||
.listFiles()
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@
|
|||
* Results are clickable to open the file at the matched line.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { Loader2, Search, X } from 'lucide-react';
|
||||
|
||||
import { getFileIcon } from './fileIcons';
|
||||
import { FileIcon } from './FileIcon';
|
||||
|
||||
import type { SearchFileResult, SearchInFilesResult } from '@shared/types/editor';
|
||||
|
||||
|
|
@ -154,13 +155,20 @@ export const SearchInFilesPanel = ({
|
|||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-xs font-medium text-text-secondary">Search in Files</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close search"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-text-muted"
|
||||
onClick={onClose}
|
||||
aria-label="Close search"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Close search (Esc)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
|
|
@ -259,9 +267,6 @@ const SearchFileGroup = ({
|
|||
const dirPath = relativePath.includes('/')
|
||||
? relativePath.slice(0, relativePath.lastIndexOf('/'))
|
||||
: '';
|
||||
const iconInfo = getFileIcon(fileName);
|
||||
const Icon = iconInfo.icon;
|
||||
|
||||
return (
|
||||
<div className="border-border/50 border-b">
|
||||
<button
|
||||
|
|
@ -269,7 +274,7 @@ const SearchFileGroup = ({
|
|||
className="flex w-full items-center gap-1.5 px-3 py-1 text-left transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
<span className="text-[10px] text-text-muted">{expanded ? '▼' : '▶'}</span>
|
||||
<Icon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
|
||||
<FileIcon fileName={fileName} className="size-3.5" />
|
||||
<span className="truncate text-xs font-medium text-text">{fileName}</span>
|
||||
{dirPath && <span className="ml-1 truncate text-[10px] text-text-muted">{dirPath}</span>}
|
||||
<span className="ml-auto shrink-0 text-[10px] text-text-muted">
|
||||
|
|
@ -310,11 +315,11 @@ interface HighlightedLineProps {
|
|||
caseSensitive: boolean;
|
||||
}
|
||||
|
||||
const HighlightedLine = ({
|
||||
const HighlightedLine = React.memo(function HighlightedLine({
|
||||
text,
|
||||
query,
|
||||
caseSensitive,
|
||||
}: HighlightedLineProps): React.ReactElement => {
|
||||
}: HighlightedLineProps): React.ReactElement {
|
||||
if (!query) {
|
||||
return <span className="truncate text-[11px] text-text-secondary">{text}</span>;
|
||||
}
|
||||
|
|
@ -351,4 +356,4 @@ const HighlightedLine = ({
|
|||
}
|
||||
|
||||
return <span className="truncate text-[11px]">{parts}</span>;
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
/**
|
||||
* File icon mapping — maps file extensions to lucide-react icon names and colors.
|
||||
* File icon mapping — maps file extensions/names to icon info.
|
||||
*
|
||||
* For programming languages and dev tools, uses Devicon CDN SVGs (colorful logos).
|
||||
* For generic file types (images, fonts, configs), falls back to lucide-react icons.
|
||||
*/
|
||||
|
||||
import {
|
||||
|
|
@ -7,7 +10,6 @@ import {
|
|||
Code,
|
||||
Database,
|
||||
File,
|
||||
FileCode,
|
||||
FileJson,
|
||||
FileText,
|
||||
FileType,
|
||||
|
|
@ -26,6 +28,22 @@ import type { LucideIcon } from 'lucide-react';
|
|||
export interface FileIconInfo {
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
/** Devicon slug — when set, FileIcon component renders the real logo from CDN */
|
||||
deviconSlug?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Devicon CDN
|
||||
// =============================================================================
|
||||
|
||||
const DEVICON_BASE = 'https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons';
|
||||
|
||||
/**
|
||||
* Build Devicon CDN URL for a given slug.
|
||||
* Uses `-original` variant (colorful) with `-wordmark` fallback.
|
||||
*/
|
||||
export function getDeviconUrl(slug: string): string {
|
||||
return `${DEVICON_BASE}/${slug}/${slug}-original.svg`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -34,74 +52,75 @@ export interface FileIconInfo {
|
|||
|
||||
const EXTENSION_MAP: Record<string, FileIconInfo> = {
|
||||
// TypeScript / JavaScript
|
||||
ts: { icon: FileCode, color: '#3178c6' },
|
||||
tsx: { icon: FileCode, color: '#3178c6' },
|
||||
js: { icon: FileCode, color: '#f7df1e' },
|
||||
jsx: { icon: FileCode, color: '#61dafb' },
|
||||
mjs: { icon: FileCode, color: '#f7df1e' },
|
||||
cjs: { icon: FileCode, color: '#f7df1e' },
|
||||
ts: { icon: Code, color: '#3178c6', deviconSlug: 'typescript' },
|
||||
tsx: { icon: Code, color: '#3178c6', deviconSlug: 'react' },
|
||||
js: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
|
||||
jsx: { icon: Code, color: '#61dafb', deviconSlug: 'react' },
|
||||
mjs: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
|
||||
cjs: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
|
||||
|
||||
// Web
|
||||
html: { icon: Code, color: '#e34c26' },
|
||||
htm: { icon: Code, color: '#e34c26' },
|
||||
css: { icon: FileCode, color: '#563d7c' },
|
||||
scss: { icon: FileCode, color: '#c6538c' },
|
||||
less: { icon: FileCode, color: '#1d365d' },
|
||||
vue: { icon: FileCode, color: '#42b883' },
|
||||
svelte: { icon: FileCode, color: '#ff3e00' },
|
||||
html: { icon: Code, color: '#e34c26', deviconSlug: 'html5' },
|
||||
htm: { icon: Code, color: '#e34c26', deviconSlug: 'html5' },
|
||||
css: { icon: Code, color: '#1572b6', deviconSlug: 'css3' },
|
||||
scss: { icon: Code, color: '#c6538c', deviconSlug: 'sass' },
|
||||
less: { icon: Code, color: '#1d365d', deviconSlug: 'less' },
|
||||
vue: { icon: Code, color: '#42b883', deviconSlug: 'vuejs' },
|
||||
svelte: { icon: Code, color: '#ff3e00', deviconSlug: 'svelte' },
|
||||
|
||||
// Data / Config
|
||||
json: { icon: FileJson, color: '#cbcb41' },
|
||||
// Data / Config (no devicon — lucide fallbacks)
|
||||
json: { icon: FileJson, color: '#cbcb41', deviconSlug: 'json' },
|
||||
jsonl: { icon: FileJson, color: '#cbcb41' },
|
||||
yaml: { icon: Settings, color: '#cb171e' },
|
||||
yml: { icon: Settings, color: '#cb171e' },
|
||||
yaml: { icon: Settings, color: '#cb171e', deviconSlug: 'yaml' },
|
||||
yml: { icon: Settings, color: '#cb171e', deviconSlug: 'yaml' },
|
||||
toml: { icon: Settings, color: '#9c4121' },
|
||||
xml: { icon: Code, color: '#e37933' },
|
||||
xml: { icon: Code, color: '#e37933', deviconSlug: 'xml' },
|
||||
csv: { icon: Database, color: '#4caf50' },
|
||||
|
||||
// Markdown / Text
|
||||
md: { icon: FileText, color: '#519aba' },
|
||||
mdx: { icon: FileText, color: '#519aba' },
|
||||
md: { icon: FileText, color: '#519aba', deviconSlug: 'markdown' },
|
||||
mdx: { icon: FileText, color: '#519aba', deviconSlug: 'markdown' },
|
||||
txt: { icon: FileText, color: '#89949f' },
|
||||
rst: { icon: FileText, color: '#89949f' },
|
||||
|
||||
// Python
|
||||
py: { icon: FileCode, color: '#3572a5' },
|
||||
pyx: { icon: FileCode, color: '#3572a5' },
|
||||
pyi: { icon: FileCode, color: '#3572a5' },
|
||||
py: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
|
||||
pyx: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
|
||||
pyi: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
|
||||
|
||||
// Rust
|
||||
rs: { icon: FileCode, color: '#dea584' },
|
||||
rs: { icon: Code, color: '#dea584', deviconSlug: 'rust' },
|
||||
|
||||
// Go
|
||||
go: { icon: FileCode, color: '#00add8' },
|
||||
go: { icon: Code, color: '#00add8', deviconSlug: 'go' },
|
||||
|
||||
// Ruby
|
||||
rb: { icon: FileCode, color: '#cc342d' },
|
||||
gemspec: { icon: FileCode, color: '#cc342d' },
|
||||
rb: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
|
||||
gemspec: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
|
||||
|
||||
// Java / Kotlin
|
||||
java: { icon: FileCode, color: '#b07219' },
|
||||
kt: { icon: FileCode, color: '#a97bff' },
|
||||
kts: { icon: FileCode, color: '#a97bff' },
|
||||
java: { icon: Code, color: '#b07219', deviconSlug: 'java' },
|
||||
kt: { icon: Code, color: '#a97bff', deviconSlug: 'kotlin' },
|
||||
kts: { icon: Code, color: '#a97bff', deviconSlug: 'kotlin' },
|
||||
|
||||
// C / C++
|
||||
c: { icon: FileCode, color: '#555555' },
|
||||
h: { icon: FileCode, color: '#555555' },
|
||||
cpp: { icon: FileCode, color: '#f34b7d' },
|
||||
hpp: { icon: FileCode, color: '#f34b7d' },
|
||||
cc: { icon: FileCode, color: '#f34b7d' },
|
||||
// C / C++ / C#
|
||||
c: { icon: Code, color: '#555555', deviconSlug: 'c' },
|
||||
h: { icon: Code, color: '#555555', deviconSlug: 'c' },
|
||||
cpp: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
|
||||
hpp: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
|
||||
cc: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
|
||||
cs: { icon: Code, color: '#178600', deviconSlug: 'csharp' },
|
||||
|
||||
// Shell
|
||||
sh: { icon: Terminal, color: '#89e051' },
|
||||
bash: { icon: Terminal, color: '#89e051' },
|
||||
zsh: { icon: Terminal, color: '#89e051' },
|
||||
sh: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
|
||||
bash: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
|
||||
zsh: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
|
||||
fish: { icon: Terminal, color: '#89e051' },
|
||||
|
||||
// SQL
|
||||
sql: { icon: Database, color: '#e38c00' },
|
||||
sql: { icon: Database, color: '#e38c00', deviconSlug: 'azuresqldatabase' },
|
||||
|
||||
// Images
|
||||
// Images (no devicon — lucide Image icon)
|
||||
png: { icon: Image, color: '#a074c4' },
|
||||
jpg: { icon: Image, color: '#a074c4' },
|
||||
jpeg: { icon: Image, color: '#a074c4' },
|
||||
|
|
@ -110,46 +129,76 @@ const EXTENSION_MAP: Record<string, FileIconInfo> = {
|
|||
ico: { icon: Image, color: '#a074c4' },
|
||||
webp: { icon: Image, color: '#a074c4' },
|
||||
|
||||
// Fonts
|
||||
// Fonts (no devicon — lucide FileType icon)
|
||||
woff: { icon: FileType, color: '#89949f' },
|
||||
woff2: { icon: FileType, color: '#89949f' },
|
||||
ttf: { icon: FileType, color: '#89949f' },
|
||||
otf: { icon: FileType, color: '#89949f' },
|
||||
|
||||
// Config files
|
||||
// Config files (no devicon — lucide icons)
|
||||
env: { icon: Lock, color: '#e5a00d' },
|
||||
ini: { icon: Settings, color: '#89949f' },
|
||||
conf: { icon: Settings, color: '#89949f' },
|
||||
cfg: { icon: Settings, color: '#89949f' },
|
||||
|
||||
// Other
|
||||
graphql: { icon: Braces, color: '#e535ab' },
|
||||
gql: { icon: Braces, color: '#e535ab' },
|
||||
proto: { icon: Code, color: '#89949f' },
|
||||
dart: { icon: FileCode, color: '#00b4ab' },
|
||||
swift: { icon: FileCode, color: '#f05138' },
|
||||
php: { icon: FileCode, color: '#4f5d95' },
|
||||
// Other languages
|
||||
graphql: { icon: Braces, color: '#e535ab', deviconSlug: 'graphql' },
|
||||
gql: { icon: Braces, color: '#e535ab', deviconSlug: 'graphql' },
|
||||
proto: { icon: Code, color: '#89949f', deviconSlug: 'protobuf' },
|
||||
dart: { icon: Code, color: '#00b4ab', deviconSlug: 'dart' },
|
||||
swift: { icon: Code, color: '#f05138', deviconSlug: 'swift' },
|
||||
php: { icon: Code, color: '#4f5d95', deviconSlug: 'php' },
|
||||
r: { icon: Code, color: '#276dc3', deviconSlug: 'r' },
|
||||
lua: { icon: Code, color: '#000080', deviconSlug: 'lua' },
|
||||
pl: { icon: Code, color: '#39457e', deviconSlug: 'perl' },
|
||||
scala: { icon: Code, color: '#dc322f', deviconSlug: 'scala' },
|
||||
groovy: { icon: Code, color: '#4298b8', deviconSlug: 'groovy' },
|
||||
ex: { icon: Code, color: '#6e4a7e', deviconSlug: 'elixir' },
|
||||
exs: { icon: Code, color: '#6e4a7e', deviconSlug: 'elixir' },
|
||||
erl: { icon: Code, color: '#b83998', deviconSlug: 'erlang' },
|
||||
hs: { icon: Code, color: '#5e5086', deviconSlug: 'haskell' },
|
||||
clj: { icon: Code, color: '#db5855', deviconSlug: 'clojure' },
|
||||
fs: { icon: Code, color: '#b845fc', deviconSlug: 'fsharp' },
|
||||
zig: { icon: Code, color: '#f7a41d', deviconSlug: 'zig' },
|
||||
nim: { icon: Code, color: '#ffc200', deviconSlug: 'nimble' },
|
||||
tf: { icon: Code, color: '#7b42bc', deviconSlug: 'terraform' },
|
||||
hcl: { icon: Code, color: '#7b42bc', deviconSlug: 'terraform' },
|
||||
};
|
||||
|
||||
// Special full filename mapping
|
||||
const FILENAME_MAP: Record<string, FileIconInfo> = {
|
||||
Dockerfile: { icon: FileCode, color: '#2496ed' },
|
||||
'docker-compose.yml': { icon: FileCode, color: '#2496ed' },
|
||||
'docker-compose.yaml': { icon: FileCode, color: '#2496ed' },
|
||||
Dockerfile: { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
|
||||
'docker-compose.yml': { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
|
||||
'docker-compose.yaml': { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
|
||||
Makefile: { icon: Terminal, color: '#427819' },
|
||||
Rakefile: { icon: Terminal, color: '#cc342d' },
|
||||
Gemfile: { icon: FileCode, color: '#cc342d' },
|
||||
'.gitignore': { icon: Settings, color: '#f05032' },
|
||||
'.gitattributes': { icon: Settings, color: '#f05032' },
|
||||
'.eslintrc': { icon: Settings, color: '#4b32c3' },
|
||||
Rakefile: { icon: Terminal, color: '#cc342d', deviconSlug: 'ruby' },
|
||||
Gemfile: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
|
||||
'.gitignore': { icon: Settings, color: '#f05032', deviconSlug: 'git' },
|
||||
'.gitattributes': { icon: Settings, color: '#f05032', deviconSlug: 'git' },
|
||||
'.eslintrc': { icon: Settings, color: '#4b32c3', deviconSlug: 'eslint' },
|
||||
'.prettierrc': { icon: Settings, color: '#56b3b4' },
|
||||
'tsconfig.json': { icon: Settings, color: '#3178c6' },
|
||||
'package.json': { icon: FileJson, color: '#cb3837' },
|
||||
'tsconfig.json': { icon: Settings, color: '#3178c6', deviconSlug: 'typescript' },
|
||||
'package.json': { icon: FileJson, color: '#cb3837', deviconSlug: 'nodejs' },
|
||||
'pnpm-lock.yaml': { icon: Lock, color: '#f69220' },
|
||||
'package-lock.json': { icon: Lock, color: '#cb3837' },
|
||||
'yarn.lock': { icon: Lock, color: '#2c8ebb' },
|
||||
'package-lock.json': { icon: Lock, color: '#cb3837', deviconSlug: 'npm' },
|
||||
'yarn.lock': { icon: Lock, color: '#2c8ebb', deviconSlug: 'yarn' },
|
||||
LICENSE: { icon: FileText, color: '#d9b611' },
|
||||
'CLAUDE.md': { icon: FileText, color: '#d97706' },
|
||||
'Cargo.toml': { icon: Settings, color: '#dea584', deviconSlug: 'rust' },
|
||||
'go.mod': { icon: Settings, color: '#00add8', deviconSlug: 'go' },
|
||||
'go.sum': { icon: Lock, color: '#00add8', deviconSlug: 'go' },
|
||||
'.dockerignore': { icon: Settings, color: '#2496ed', deviconSlug: 'docker' },
|
||||
'vite.config.ts': { icon: Settings, color: '#646cff', deviconSlug: 'vitejs' },
|
||||
'vite.config.js': { icon: Settings, color: '#646cff', deviconSlug: 'vitejs' },
|
||||
'webpack.config.js': { icon: Settings, color: '#8dd6f9', deviconSlug: 'webpack' },
|
||||
'webpack.config.ts': { icon: Settings, color: '#8dd6f9', deviconSlug: 'webpack' },
|
||||
'.babelrc': { icon: Settings, color: '#f5da55', deviconSlug: 'babel' },
|
||||
'babel.config.js': { icon: Settings, color: '#f5da55', deviconSlug: 'babel' },
|
||||
'tailwind.config.js': { icon: Settings, color: '#06b6d4', deviconSlug: 'tailwindcss' },
|
||||
'tailwind.config.ts': { icon: Settings, color: '#06b6d4', deviconSlug: 'tailwindcss' },
|
||||
'next.config.js': { icon: Settings, color: '#000000', deviconSlug: 'nextjs' },
|
||||
'next.config.mjs': { icon: Settings, color: '#000000', deviconSlug: 'nextjs' },
|
||||
'nuxt.config.ts': { icon: Settings, color: '#00dc82', deviconSlug: 'nuxtjs' },
|
||||
};
|
||||
|
||||
const DEFAULT_ICON: FileIconInfo = { icon: File, color: '#89949f' };
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -76,6 +77,41 @@ const DependencyBadge = ({
|
|||
);
|
||||
};
|
||||
|
||||
const TruncatedTitle = ({
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}): React.JSX.Element => {
|
||||
const ref = useRef<HTMLHeadingElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
const checkTruncation = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (el) {
|
||||
setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tooltip open={isTruncated ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<h5
|
||||
ref={ref}
|
||||
className={`truncate text-sm font-medium text-[var(--color-text)] ${className ?? ''}`}
|
||||
onMouseEnter={checkTruncation}
|
||||
>
|
||||
{text}
|
||||
</h5>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
{text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const CancelTaskButton = ({
|
||||
taskId,
|
||||
onConfirm,
|
||||
|
|
@ -228,11 +264,7 @@ export const KanbanTaskCard = ({
|
|||
#{task.id}
|
||||
</Badge>
|
||||
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
|
||||
{!compact && (
|
||||
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{task.subject}
|
||||
</h5>
|
||||
)}
|
||||
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
||||
</div>
|
||||
{task.needsClarification ? (
|
||||
<span
|
||||
|
|
@ -246,11 +278,7 @@ export const KanbanTaskCard = ({
|
|||
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
||||
</span>
|
||||
) : null}
|
||||
{compact && (
|
||||
<h5 className="mt-1 truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{task.subject}
|
||||
</h5>
|
||||
)}
|
||||
{compact && <TruncatedTitle text={task.subject} className="mt-1" />}
|
||||
</div>
|
||||
|
||||
{hasBlockedBy ? (
|
||||
|
|
|
|||
|
|
@ -32,7 +32,13 @@ export const MemberList = ({
|
|||
onAssignTask,
|
||||
onOpenTask,
|
||||
}: MemberListProps): React.JSX.Element => {
|
||||
const activeMembers = members.filter((m) => !m.removedAt);
|
||||
const activeMembers = members
|
||||
.filter((m) => !m.removedAt)
|
||||
.sort((a, b) => {
|
||||
if (a.agentType === 'team-lead') return -1;
|
||||
if (b.agentType === 'team-lead') return 1;
|
||||
return 0;
|
||||
});
|
||||
const removedMembers = members.filter((m) => m.removedAt);
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ interface MemberLogsTabProps {
|
|||
/** When viewing task logs: include owner's sessions when task is in_progress */
|
||||
taskOwner?: string;
|
||||
taskStatus?: string;
|
||||
/** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */
|
||||
taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
|
||||
}
|
||||
|
||||
export const MemberLogsTab = ({
|
||||
|
|
@ -32,6 +34,7 @@ export const MemberLogsTab = ({
|
|||
taskId,
|
||||
taskOwner,
|
||||
taskStatus,
|
||||
taskWorkIntervals,
|
||||
}: MemberLogsTabProps): React.JSX.Element => {
|
||||
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -61,6 +64,7 @@ export const MemberLogsTab = ({
|
|||
? await api.teams.getLogsForTask(teamName, taskId, {
|
||||
owner: taskOwner,
|
||||
status: taskStatus,
|
||||
intervals: taskWorkIntervals,
|
||||
})
|
||||
: await api.teams.getMemberLogs(teamName, memberName!);
|
||||
if (!cancelled) {
|
||||
|
|
@ -86,7 +90,7 @@ export const MemberLogsTab = ({
|
|||
cancelled = true;
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus]);
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, taskWorkIntervals]);
|
||||
|
||||
const handleExpand = useCallback(
|
||||
async (log: MemberLogSummary) => {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useAttachments } from '@renderer/hooks/useAttachments';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -50,7 +54,9 @@ export const MessageComposer = ({
|
|||
const dragCounterRef = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||
const draft = useDraftPersistence({ key: `compose:${teamName}` });
|
||||
const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`);
|
||||
const {
|
||||
attachments,
|
||||
error: attachmentError,
|
||||
|
|
@ -88,11 +94,23 @@ export const MessageComposer = ({
|
|||
// Track whether we initiated a send — clear draft only on confirmed success
|
||||
const pendingSendRef = useRef(false);
|
||||
|
||||
const handleChipRemove = useCallback(
|
||||
(chipId: string) => {
|
||||
const chip = chipDraft.chips.find((c) => c.id === chipId);
|
||||
if (chip) {
|
||||
draft.setValue(removeChipTokenFromText(draft.value, chip));
|
||||
}
|
||||
chipDraft.setChips(chipDraft.chips.filter((c) => c.id !== chipId));
|
||||
},
|
||||
[chipDraft, draft]
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!canSend) return;
|
||||
pendingSendRef.current = true;
|
||||
onSend(recipient, trimmed, trimmed, attachments.length > 0 ? attachments : undefined);
|
||||
}, [canSend, recipient, trimmed, onSend, attachments]);
|
||||
const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
|
||||
onSend(recipient, serialized, serialized, attachments.length > 0 ? attachments : undefined);
|
||||
}, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]);
|
||||
|
||||
// Clear draft only after send completes successfully (sending: true → false, no error)
|
||||
useEffect(() => {
|
||||
|
|
@ -100,10 +118,12 @@ export const MessageComposer = ({
|
|||
pendingSendRef.current = false;
|
||||
if (!sendError) {
|
||||
draft.clearDraft();
|
||||
chipDraft.clearChipDraft();
|
||||
clearAttachments();
|
||||
}
|
||||
}
|
||||
}, [sending, sendError, draft, clearAttachments]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearChipDraft is stable (useCallback with [])
|
||||
}, [sending, sendError, draft, clearAttachments, chipDraft.clearChipDraft]);
|
||||
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
|
|
@ -304,6 +324,10 @@ export const MessageComposer = ({
|
|||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
chips={chipDraft.chips}
|
||||
onChipRemove={handleChipRemove}
|
||||
projectPath={projectPath}
|
||||
onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])}
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
maxLength={MAX_MESSAGE_LENGTH}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { undo } from '@codemirror/commands';
|
||||
import { goToNextChunk, rejectChunk } from '@codemirror/merge';
|
||||
import { rejectChunk } from '@codemirror/merge';
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { EditorSelectionMenu } from '@renderer/components/team/editor/EditorSelectionMenu';
|
||||
import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav';
|
||||
import { isLastChunkInFile, useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
||||
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
||||
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice';
|
||||
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
|
||||
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
|
||||
import { ChevronDown, Clock, X } from 'lucide-react';
|
||||
|
||||
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||
import { ContinuousScrollView } from './ContinuousScrollView';
|
||||
import { FileEditTimeline } from './FileEditTimeline';
|
||||
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
|
||||
|
|
@ -22,6 +25,7 @@ import { ViewedProgressBar } from './ViewedProgressBar';
|
|||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { HunkDecision, TaskChangeSetV2 } from '@shared/types';
|
||||
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
interface ChangeReviewDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -31,6 +35,8 @@ interface ChangeReviewDialogProps {
|
|||
memberName?: string;
|
||||
taskId?: string;
|
||||
initialFilePath?: string;
|
||||
projectPath?: string;
|
||||
onEditorAction?: (action: EditorSelectionAction) => void;
|
||||
}
|
||||
|
||||
function isTaskChangeSetV2(cs: { teamName: string }): cs is TaskChangeSetV2 {
|
||||
|
|
@ -45,6 +51,8 @@ export const ChangeReviewDialog = ({
|
|||
memberName,
|
||||
taskId,
|
||||
initialFilePath,
|
||||
projectPath,
|
||||
onEditorAction,
|
||||
}: ChangeReviewDialogProps): React.ReactElement | null => {
|
||||
const {
|
||||
activeChangeSet,
|
||||
|
|
@ -61,6 +69,7 @@ export const ChangeReviewDialog = ({
|
|||
applying,
|
||||
applyError,
|
||||
setHunkDecision,
|
||||
clearHunkDecisionByOriginalIndex,
|
||||
setCollapseUnchanged,
|
||||
fetchFileContent,
|
||||
acceptAllFile,
|
||||
|
|
@ -79,6 +88,7 @@ export const ChangeReviewDialog = ({
|
|||
pushReviewUndoSnapshot,
|
||||
undoBulkReview,
|
||||
reviewUndoStack,
|
||||
hunkContextHashesByFile,
|
||||
} = useStore();
|
||||
|
||||
// Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll)
|
||||
|
|
@ -87,11 +97,23 @@ export const ChangeReviewDialog = ({
|
|||
const [timelineOpen, setTimelineOpen] = useState(false);
|
||||
const [discardCounters, setDiscardCounters] = useState<Record<string, number>>({});
|
||||
|
||||
// Selection menu state
|
||||
const [selectionInfo, setSelectionInfo] = useState<EditorSelectionInfo | null>(null);
|
||||
const [containerRect, setContainerRect] = useState<DOMRect>(new DOMRect());
|
||||
const diffContentRef = useRef<HTMLDivElement>(null);
|
||||
const selectionTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const activeSelectionFileRef = useRef<string | null>(null);
|
||||
|
||||
// EditorView map for all visible file editors
|
||||
const editorViewMapRef = useRef(new Map<string, EditorView>());
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
// Last focused CM editor — for Cmd+Z outside editor
|
||||
const lastFocusedEditorRef = useRef<EditorView | null>(null);
|
||||
// Timestamp of last bulk accept/reject-all operation (for Ctrl/Cmd+Z UX)
|
||||
const lastBulkActionAtRef = useRef<number>(0);
|
||||
// Track recent per-hunk actions so Ctrl/Cmd+Z can clear persisted decisions (reopen-safe)
|
||||
const lastHunkActionAtRef = useRef<Record<string, number>>({});
|
||||
const hunkDecisionUndoStackRef = useRef<Record<string, number[]>>({});
|
||||
|
||||
// Proxy ref for useDiffNavigation (points to active file's editor)
|
||||
const activeEditorViewRef = useRef<EditorView | null>(null);
|
||||
|
|
@ -155,6 +177,7 @@ export const ChangeReviewDialog = ({
|
|||
const handleAcceptAll = useCallback(() => {
|
||||
if (!activeChangeSet) return;
|
||||
pushReviewUndoSnapshot();
|
||||
lastBulkActionAtRef.current = Date.now();
|
||||
for (const file of activeChangeSet.files) {
|
||||
acceptAllFile(file.filePath);
|
||||
}
|
||||
|
|
@ -168,6 +191,7 @@ export const ChangeReviewDialog = ({
|
|||
const handleRejectAll = useCallback(() => {
|
||||
if (!activeChangeSet) return;
|
||||
pushReviewUndoSnapshot();
|
||||
lastBulkActionAtRef.current = Date.now();
|
||||
for (const file of activeChangeSet.files) {
|
||||
rejectAllFile(file.filePath);
|
||||
}
|
||||
|
|
@ -181,14 +205,24 @@ export const ChangeReviewDialog = ({
|
|||
// Per-file callbacks for ContinuousScrollView
|
||||
const handleHunkAccepted = useCallback(
|
||||
(filePath: string, hunkIndex: number) => {
|
||||
setHunkDecision(filePath, hunkIndex, 'accepted');
|
||||
const originalIndex = setHunkDecision(filePath, hunkIndex, 'accepted');
|
||||
lastHunkActionAtRef.current[filePath] = Date.now();
|
||||
if (!hunkDecisionUndoStackRef.current[filePath]) {
|
||||
hunkDecisionUndoStackRef.current[filePath] = [];
|
||||
}
|
||||
hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
|
||||
},
|
||||
[setHunkDecision]
|
||||
);
|
||||
|
||||
const handleHunkRejected = useCallback(
|
||||
(filePath: string, hunkIndex: number) => {
|
||||
setHunkDecision(filePath, hunkIndex, 'rejected');
|
||||
const originalIndex = setHunkDecision(filePath, hunkIndex, 'rejected');
|
||||
lastHunkActionAtRef.current[filePath] = Date.now();
|
||||
if (!hunkDecisionUndoStackRef.current[filePath]) {
|
||||
hunkDecisionUndoStackRef.current[filePath] = [];
|
||||
}
|
||||
hunkDecisionUndoStackRef.current[filePath].push(originalIndex);
|
||||
if (REVIEW_INSTANT_APPLY) {
|
||||
void applySingleFileDecision(teamName, filePath, taskId, memberName);
|
||||
}
|
||||
|
|
@ -214,9 +248,18 @@ export const ChangeReviewDialog = ({
|
|||
|
||||
const handleSaveFile = useCallback(
|
||||
(filePath: string) => {
|
||||
void saveEditedFile(filePath);
|
||||
void saveEditedFile(filePath, projectPath);
|
||||
},
|
||||
[saveEditedFile]
|
||||
[saveEditedFile, projectPath]
|
||||
);
|
||||
|
||||
const handleRestoreMissingFile = useCallback(
|
||||
(filePath: string, content: string) => {
|
||||
updateEditedContent(filePath, content);
|
||||
// Ensure editedContents is set before saveEditedFile reads it.
|
||||
void Promise.resolve().then(() => saveEditedFile(filePath, projectPath));
|
||||
},
|
||||
[updateEditedContent, saveEditedFile, projectPath]
|
||||
);
|
||||
|
||||
const handleDiscardFile = useCallback(
|
||||
|
|
@ -242,10 +285,72 @@ export const ChangeReviewDialog = ({
|
|||
}
|
||||
}, [undoBulkReview, activeChangeSet]);
|
||||
|
||||
// Save active file (for Cmd+Enter keyboard shortcut)
|
||||
// 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+S keyboard shortcut)
|
||||
const handleSaveActiveFile = useCallback(() => {
|
||||
if (activeFilePath) void saveEditedFile(activeFilePath);
|
||||
}, [activeFilePath, saveEditedFile]);
|
||||
if (activeFilePath) void saveEditedFile(activeFilePath, projectPath);
|
||||
}, [activeFilePath, saveEditedFile, projectPath]);
|
||||
|
||||
// Continuous navigation options for cross-file hunk navigation
|
||||
const continuousOptions = useMemo(
|
||||
|
|
@ -268,7 +373,9 @@ export const ChangeReviewDialog = ({
|
|||
handleHunkRejected,
|
||||
() => onOpenChange(false),
|
||||
handleSaveActiveFile,
|
||||
continuousOptions
|
||||
continuousOptions,
|
||||
(filePath, fallbackSnippetsLength) =>
|
||||
getFileHunkCount(filePath, fallbackSnippetsLength, fileChunkCounts)
|
||||
);
|
||||
|
||||
// Load data on open
|
||||
|
|
@ -342,6 +449,23 @@ export const ChangeReviewDialog = ({
|
|||
});
|
||||
}, [activeChangeSet, initialFilePath, scrollToFile]);
|
||||
|
||||
// Clear selection state on close (React-approved setState-during-render pattern)
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (prevOpen !== open) {
|
||||
setPrevOpen(open);
|
||||
if (!open) {
|
||||
setSelectionInfo(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup refs/timers on close
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
activeSelectionFileRef.current = null;
|
||||
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Escape to close
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -379,53 +503,74 @@ export const ChangeReviewDialog = ({
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
||||
// Don't intercept if focus is inside a CM editor — let CM handle its own undo
|
||||
if (document.activeElement?.closest('.cm-editor')) return;
|
||||
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyZ' && !e.shiftKey) {
|
||||
// Don't intercept native undo in input/textarea
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
|
||||
// Try to undo in the last focused CM editor
|
||||
// Prefer bulk undo (Accept All / Reject All) shortly after bulk action,
|
||||
// even if focus is inside a CM editor (focus often remains there after clicking buttons).
|
||||
const now = Date.now();
|
||||
const bulkRecently = now - lastBulkActionAtRef.current < 10_000;
|
||||
if (bulkRecently && useStore.getState().reviewUndoStack.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleUndoBulk();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the last action was a hunk keep/undo (accept/reject) and we're undoing immediately,
|
||||
// we must also clear the persisted decision. Otherwise reopening the dialog will replay it.
|
||||
if (document.activeElement?.closest('.cm-editor')) {
|
||||
const lastView = lastFocusedEditorRef.current;
|
||||
const fp = activeFilePathRef.current;
|
||||
const stack = fp ? hunkDecisionUndoStackRef.current[fp] : undefined;
|
||||
const lastAt = fp ? (lastHunkActionAtRef.current[fp] ?? 0) : 0;
|
||||
const hunkRecently = fp ? now - lastAt < 5_000 : false;
|
||||
|
||||
if (fp && stack && stack.length > 0 && hunkRecently && lastView?.dom.isConnected) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
undo(lastView);
|
||||
const originalIndex = stack.pop()!;
|
||||
clearHunkDecisionByOriginalIndex(fp, originalIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, let CM handle its own undo
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise try to undo in the last focused CM editor
|
||||
const lastView = lastFocusedEditorRef.current;
|
||||
if (lastView?.dom.isConnected) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
undo(lastView);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to bulk undo (Accept All / Reject All)
|
||||
if (useStore.getState().reviewUndoStack.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleUndoBulk();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler, true);
|
||||
return () => document.removeEventListener('keydown', handler, true);
|
||||
}, [open, handleUndoBulk]);
|
||||
}, [open, handleUndoBulk, clearHunkDecisionByOriginalIndex]);
|
||||
|
||||
// Cmd+N IPC listener (forwarded from main process)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
|
||||
const fp = activeFilePathRef.current;
|
||||
const view = fp ? editorViewMapRef.current.get(fp) : null;
|
||||
if (view) {
|
||||
rejectChunk(view);
|
||||
requestAnimationFrame(() => {
|
||||
if (isLastChunkInFile(view)) {
|
||||
diffNav.goToNextFile();
|
||||
} else {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!fp) return;
|
||||
const view = editorViewMapRef.current.get(fp);
|
||||
if (!view) return;
|
||||
|
||||
const cursorPos = view.state.selection.main.head;
|
||||
const idx = computeChunkIndexAtPos(view.state, cursorPos);
|
||||
handleHunkRejected(fp, idx);
|
||||
rejectChunk(view);
|
||||
requestAnimationFrame(() => diffNav.goToNextHunk());
|
||||
});
|
||||
return cleanup ?? undefined;
|
||||
}, [open, diffNav]);
|
||||
}, [open, diffNav, handleHunkRejected]);
|
||||
|
||||
// Compute toolbar stats using actual CM chunk count (not snippet count)
|
||||
const reviewStats = useMemo(() => {
|
||||
|
|
@ -636,33 +781,55 @@ export const ChangeReviewDialog = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Continuous scroll diff content */}
|
||||
<ContinuousScrollView
|
||||
files={activeChangeSet.files}
|
||||
fileContents={fileContents}
|
||||
fileContentsLoading={fileContentsLoading}
|
||||
viewedSet={viewedSet}
|
||||
editedContents={editedContents}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
applying={applying}
|
||||
autoViewed={autoViewed}
|
||||
discardCounters={discardCounters}
|
||||
onHunkAccepted={handleHunkAccepted}
|
||||
onHunkRejected={handleHunkRejected}
|
||||
onFullyViewed={handleFullyViewed}
|
||||
onContentChanged={handleContentChanged}
|
||||
onDiscard={handleDiscardFile}
|
||||
onSave={handleSaveFile}
|
||||
onVisibleFileChange={handleVisibleFileChange}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
editorViewMapRef={editorViewMapRef}
|
||||
isProgrammaticScroll={isProgrammaticScroll}
|
||||
teamName={teamName}
|
||||
memberName={memberName}
|
||||
fetchFileContent={fetchFileContent}
|
||||
/>
|
||||
{/* Continuous scroll diff content with selection menu */}
|
||||
<div
|
||||
ref={diffContentRef}
|
||||
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<ContinuousScrollView
|
||||
files={activeChangeSet.files}
|
||||
fileContents={fileContents}
|
||||
fileContentsLoading={fileContentsLoading}
|
||||
viewedSet={viewedSet}
|
||||
editedContents={editedContents}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
hunkContextHashesByFile={hunkContextHashesByFile}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
applying={applying}
|
||||
autoViewed={autoViewed}
|
||||
discardCounters={discardCounters}
|
||||
onHunkAccepted={handleHunkAccepted}
|
||||
onHunkRejected={handleHunkRejected}
|
||||
onFullyViewed={handleFullyViewed}
|
||||
onContentChanged={handleContentChanged}
|
||||
onDiscard={handleDiscardFile}
|
||||
onSave={handleSaveFile}
|
||||
onRestoreMissingFile={handleRestoreMissingFile}
|
||||
onVisibleFileChange={handleVisibleFileChange}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
editorViewMapRef={editorViewMapRef}
|
||||
isProgrammaticScroll={isProgrammaticScroll}
|
||||
teamName={teamName}
|
||||
memberName={memberName}
|
||||
fetchFileContent={fetchFileContent}
|
||||
onSelectionChange={onEditorAction ? handleSelectionChange : undefined}
|
||||
/>
|
||||
{selectionInfo && onEditorAction && (
|
||||
<EditorSelectionMenu
|
||||
info={selectionInfo}
|
||||
containerRect={containerRect}
|
||||
onSendMessage={() => {
|
||||
onEditorAction(buildSelectionAction('sendMessage', selectionInfo));
|
||||
setSelectionInfo(null);
|
||||
}}
|
||||
onCreateTask={() => {
|
||||
onEditorAction(buildSelectionAction('createTask', selectionInfo));
|
||||
setSelectionInfo(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
} from '@codemirror/merge';
|
||||
import { ChangeSet, type ChangeSpec, EditorState, type StateEffect } from '@codemirror/state';
|
||||
import { type EditorView } from '@codemirror/view';
|
||||
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||
import { structuredPatch } from 'diff';
|
||||
|
||||
/**
|
||||
* Teaches CM history to undo acceptChunk operations (updateOriginalDoc effects).
|
||||
|
|
@ -40,15 +42,13 @@ export function acceptAllChunks(view: EditorView): boolean {
|
|||
if (!result || result.chunks.length === 0) return false;
|
||||
|
||||
const orig = getOriginalDoc(view.state);
|
||||
const specs: ChangeSpec[] = [];
|
||||
for (const chunk of result.chunks) {
|
||||
specs.push({
|
||||
from: chunk.fromA,
|
||||
to: chunk.toA,
|
||||
insert: view.state.doc.sliceString(chunk.fromB, chunk.toB),
|
||||
});
|
||||
}
|
||||
const changes = ChangeSet.of(specs, orig.length);
|
||||
// More robust than per-chunk: merge chunk ranges can be inconsistent for "empty" originals
|
||||
// (e.g. whitespace-only or reconstruction edge cases), which can throw RangeError.
|
||||
// Accept-all semantics are simply: make original equal to current modified doc.
|
||||
const changes = ChangeSet.of(
|
||||
[{ from: 0, to: orig.length, insert: view.state.doc.toString() }],
|
||||
orig.length
|
||||
);
|
||||
view.dispatch({
|
||||
effects: updateOriginalDoc.of({ doc: changes.apply(orig), changes }),
|
||||
});
|
||||
|
|
@ -61,15 +61,11 @@ export function rejectAllChunks(view: EditorView): boolean {
|
|||
if (!result || result.chunks.length === 0) return false;
|
||||
|
||||
const orig = getOriginalDoc(view.state);
|
||||
const specs: ChangeSpec[] = [];
|
||||
for (const chunk of result.chunks) {
|
||||
specs.push({
|
||||
from: chunk.fromB,
|
||||
to: chunk.toB,
|
||||
insert: orig.sliceString(chunk.fromA, chunk.toA),
|
||||
});
|
||||
}
|
||||
view.dispatch({ changes: specs });
|
||||
// Same robustness principle as acceptAllChunks: reject-all semantics are simply
|
||||
// "restore the current doc to the original baseline" in one edit.
|
||||
view.dispatch({
|
||||
changes: [{ from: 0, to: view.state.doc.length, insert: orig.toString() }],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -130,4 +126,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 };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { indentUnit, syntaxHighlighting } from '@codemirror/language';
|
||||
import { foldGutter, foldKeymap, indentUnit, syntaxHighlighting } from '@codemirror/language';
|
||||
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
|
||||
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
getAsyncLanguageDesc,
|
||||
getSyncLanguageExtension,
|
||||
} from '@renderer/utils/codemirrorLanguages';
|
||||
import { buildSelectionInfo } from '@renderer/utils/codemirrorSelectionInfo';
|
||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||
|
||||
import {
|
||||
|
|
@ -21,6 +22,8 @@ import {
|
|||
} from './CodeMirrorDiffUtils';
|
||||
import { portionCollapseExtension } from './portionCollapse';
|
||||
|
||||
import type { EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
interface CodeMirrorDiffViewProps {
|
||||
original: string;
|
||||
modified: string;
|
||||
|
|
@ -46,6 +49,8 @@ interface CodeMirrorDiffViewProps {
|
|||
usePortionCollapse?: boolean;
|
||||
/** Lines per "Expand N" click (only with usePortionCollapse). Default: 100 */
|
||||
portionSize?: number;
|
||||
/** Called when text selection changes (for floating action menu) */
|
||||
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
||||
}
|
||||
|
||||
/** Compute hunk index for the chunk at a given position (B-side / modified doc).
|
||||
|
|
@ -162,6 +167,17 @@ const diffSpecificTheme = EditorView.theme({
|
|||
},
|
||||
});
|
||||
|
||||
/** When original is empty (all additions), avoid showing a stray "deleted" block at the top. */
|
||||
const emptyOriginalOverrideTheme = EditorView.theme({
|
||||
'.cm-deletedChunk': {
|
||||
backgroundColor: 'transparent !important',
|
||||
paddingLeft: '0 !important',
|
||||
},
|
||||
'.cm-deletedLine': {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
});
|
||||
|
||||
export const CodeMirrorDiffView = ({
|
||||
original,
|
||||
modified,
|
||||
|
|
@ -180,6 +196,7 @@ export const CodeMirrorDiffView = ({
|
|||
initialState,
|
||||
usePortionCollapse = false,
|
||||
portionSize = 100,
|
||||
onSelectionChange,
|
||||
}: CodeMirrorDiffViewProps): React.ReactElement => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
|
@ -192,14 +209,23 @@ export const CodeMirrorDiffView = ({
|
|||
const onRejectRef = useRef(onHunkRejected);
|
||||
const onContentChangedRef = useRef(onContentChanged);
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
onAcceptRef.current = onHunkAccepted;
|
||||
onRejectRef.current = onHunkRejected;
|
||||
onContentChangedRef.current = onContentChanged;
|
||||
onViewChangeRef.current = onViewChange;
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
externalViewRefHolder.current = externalViewRef;
|
||||
}, [onHunkAccepted, onHunkRejected, onContentChanged, onViewChange, externalViewRef]);
|
||||
}, [
|
||||
onHunkAccepted,
|
||||
onHunkRejected,
|
||||
onContentChanged,
|
||||
onViewChange,
|
||||
onSelectionChange,
|
||||
externalViewRef,
|
||||
]);
|
||||
|
||||
// Auto-scroll to next chunk after accept/reject (deferred to let CM recalculate)
|
||||
const scrollToNextChunk = useCallback(() => {
|
||||
|
|
@ -231,6 +257,13 @@ export const CodeMirrorDiffView = ({
|
|||
syntaxHighlightDeletions: true,
|
||||
};
|
||||
|
||||
// IMPORTANT: @codemirror/merge shows accept/reject buttons by default.
|
||||
// When our UI chooses to hide merge controls (e.g. "Missing on disk" preview),
|
||||
// explicitly disable them rather than relying on default behavior.
|
||||
if (!showMergeControls) {
|
||||
mergeConfig.mergeControls = false;
|
||||
}
|
||||
|
||||
if (collapse && !usePortionCollapse) {
|
||||
mergeConfig.collapseUnchanged = {
|
||||
margin,
|
||||
|
|
@ -355,11 +388,14 @@ export const CodeMirrorDiffView = ({
|
|||
);
|
||||
|
||||
const buildExtensions = useCallback(() => {
|
||||
const isEffectivelyEmptyOriginal = original.trim().length === 0;
|
||||
const extensions: Extension[] = [
|
||||
baseEditorTheme,
|
||||
diffSpecificTheme,
|
||||
...(isEffectivelyEmptyOriginal ? [emptyOriginalOverrideTheme] : []),
|
||||
lineNumbers(),
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
foldGutter(),
|
||||
EditorView.editable.of(!readOnly),
|
||||
EditorState.readOnly.of(readOnly),
|
||||
];
|
||||
|
|
@ -370,7 +406,9 @@ export const CodeMirrorDiffView = ({
|
|||
extensions.push(mergeUndoSupport);
|
||||
extensions.push(mirrorEditsAfterResolve);
|
||||
extensions.push(indentUnit.of(' '));
|
||||
extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]));
|
||||
extensions.push(
|
||||
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...foldKeymap])
|
||||
);
|
||||
}
|
||||
|
||||
// Language placeholder — actual language injected async via compartment reconfigure
|
||||
|
|
@ -391,6 +429,7 @@ export const CodeMirrorDiffView = ({
|
|||
key: 'Ctrl-Alt-ArrowUp',
|
||||
run: goToPreviousChunk,
|
||||
},
|
||||
...foldKeymap,
|
||||
])
|
||||
);
|
||||
|
||||
|
|
@ -408,6 +447,20 @@ export const CodeMirrorDiffView = ({
|
|||
);
|
||||
}
|
||||
|
||||
// Selection change listener (for floating action menu)
|
||||
extensions.push(
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const sel = update.state.selection.main;
|
||||
if (sel.empty) {
|
||||
onSelectionChangeRef.current?.(null);
|
||||
} else {
|
||||
onSelectionChangeRef.current?.(buildSelectionInfo(update.view, sel));
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
|
||||
if (showMergeControls) {
|
||||
// Helper: position a chunkButtons container so it's below the change block,
|
||||
|
|
@ -566,7 +619,7 @@ export const CodeMirrorDiffView = ({
|
|||
);
|
||||
|
||||
return extensions;
|
||||
}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize]);
|
||||
}, [readOnly, showMergeControls, buildMergeExtension, usePortionCollapse, portionSize, original]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
acceptAllChunks,
|
||||
getChunks,
|
||||
rejectAllChunks,
|
||||
replayHunkDecisions,
|
||||
replayHunkDecisionsSmart,
|
||||
} from './CodeMirrorDiffUtils';
|
||||
import { FileSectionDiff } from './FileSectionDiff';
|
||||
import { FileSectionHeader } from './FileSectionHeader';
|
||||
|
|
@ -16,6 +16,7 @@ import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
|||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
||||
import type { EditorSelectionInfo } from '@shared/types/editor';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
interface ContinuousScrollViewProps {
|
||||
|
|
@ -26,6 +27,7 @@ interface ContinuousScrollViewProps {
|
|||
editedContents: Record<string, string>;
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
hunkContextHashesByFile: Record<string, Record<number, string>>;
|
||||
collapseUnchanged: boolean;
|
||||
applying: boolean;
|
||||
autoViewed: boolean;
|
||||
|
|
@ -36,6 +38,7 @@ interface ContinuousScrollViewProps {
|
|||
onContentChanged: (filePath: string, content: string) => void;
|
||||
onDiscard: (filePath: string) => void;
|
||||
onSave: (filePath: string) => void;
|
||||
onRestoreMissingFile?: (filePath: string, content: string) => void;
|
||||
onVisibleFileChange: (filePath: string) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement>;
|
||||
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
|
||||
|
|
@ -47,6 +50,7 @@ interface ContinuousScrollViewProps {
|
|||
memberName: string | undefined,
|
||||
filePath: string
|
||||
) => Promise<void>;
|
||||
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
||||
}
|
||||
|
||||
export const ContinuousScrollView = ({
|
||||
|
|
@ -57,6 +61,7 @@ export const ContinuousScrollView = ({
|
|||
editedContents,
|
||||
hunkDecisions,
|
||||
fileDecisions,
|
||||
hunkContextHashesByFile,
|
||||
collapseUnchanged,
|
||||
applying,
|
||||
autoViewed,
|
||||
|
|
@ -67,6 +72,7 @@ export const ContinuousScrollView = ({
|
|||
onContentChanged,
|
||||
onDiscard,
|
||||
onSave,
|
||||
onRestoreMissingFile,
|
||||
onVisibleFileChange,
|
||||
scrollContainerRef,
|
||||
editorViewMapRef,
|
||||
|
|
@ -74,6 +80,7 @@ export const ContinuousScrollView = ({
|
|||
teamName,
|
||||
memberName,
|
||||
fetchFileContent,
|
||||
onSelectionChange,
|
||||
}: ContinuousScrollViewProps): React.ReactElement => {
|
||||
const setFileChunkCount = useStore((s) => s.setFileChunkCount);
|
||||
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
|
||||
|
|
@ -126,9 +133,11 @@ export const ContinuousScrollView = ({
|
|||
// Refs to avoid stale closures — decisions change frequently
|
||||
const fileDecisionsRef = useRef(fileDecisions);
|
||||
const hunkDecisionsRef = useRef(hunkDecisions);
|
||||
const hunkHashesRef = useRef(hunkContextHashesByFile);
|
||||
useEffect(() => {
|
||||
fileDecisionsRef.current = fileDecisions;
|
||||
hunkDecisionsRef.current = hunkDecisions;
|
||||
hunkHashesRef.current = hunkContextHashesByFile;
|
||||
});
|
||||
|
||||
// Track which views have already had decisions replayed to prevent
|
||||
|
|
@ -166,7 +175,12 @@ export const ContinuousScrollView = ({
|
|||
} else {
|
||||
// Replay individual per-hunk decisions persisted from previous session
|
||||
requestAnimationFrame(() => {
|
||||
replayHunkDecisions(view, filePath, hunkDecisionsRef.current);
|
||||
replayHunkDecisionsSmart(
|
||||
view,
|
||||
filePath,
|
||||
hunkDecisionsRef.current,
|
||||
hunkHashesRef.current[filePath]
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -210,6 +224,7 @@ export const ContinuousScrollView = ({
|
|||
onToggleCollapse={handleToggleCollapse}
|
||||
onDiscard={onDiscard}
|
||||
onSave={onSave}
|
||||
onRestoreMissingFile={onRestoreMissingFile}
|
||||
/>
|
||||
|
||||
{!isCollapsed &&
|
||||
|
|
@ -227,6 +242,7 @@ export const ContinuousScrollView = ({
|
|||
discardCounter={discardCounters[filePath] ?? 0}
|
||||
autoViewed={autoViewed}
|
||||
isViewed={isViewed}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
) : (
|
||||
<FileSectionPlaceholder fileName={file.relativePath} />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ReviewDiffContent } from './ReviewDiffContent';
|
|||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { FileChangeWithContent } from '@shared/types';
|
||||
import type { EditorSelectionInfo } from '@shared/types/editor';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
interface FileSectionDiffProps {
|
||||
|
|
@ -22,6 +23,7 @@ interface FileSectionDiffProps {
|
|||
discardCounter: number;
|
||||
autoViewed: boolean;
|
||||
isViewed: boolean;
|
||||
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
||||
}
|
||||
|
||||
export const FileSectionDiff = ({
|
||||
|
|
@ -37,6 +39,7 @@ export const FileSectionDiff = ({
|
|||
discardCounter,
|
||||
autoViewed,
|
||||
isViewed,
|
||||
onSelectionChange,
|
||||
}: FileSectionDiffProps): React.ReactElement => {
|
||||
const localEditorViewRef = useRef<EditorView | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -89,10 +92,17 @@ export const FileSectionDiff = ({
|
|||
return writeSnippets[writeSnippets.length - 1].newString;
|
||||
})();
|
||||
|
||||
const hasCodeMirrorContent =
|
||||
fileContent && fileContent.contentSource !== 'unavailable' && resolvedModified !== null;
|
||||
const resolvedOriginal = fileContent?.originalFullContent ?? null;
|
||||
const isMissingOnDisk = fileContent?.contentSource === 'unavailable';
|
||||
|
||||
if (!hasCodeMirrorContent) {
|
||||
// Show CodeMirror only when we have a trustworthy original baseline:
|
||||
// - new files: original is legitimately empty
|
||||
// - otherwise: original must be known (non-null). If original is unknown, do not
|
||||
// pretend it's empty — fall back to snippet-level diff.
|
||||
const canRenderCodeMirror =
|
||||
resolvedModified !== null && (file.isNewFile || resolvedOriginal !== null);
|
||||
|
||||
if (!canRenderCodeMirror) {
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<ReviewDiffContent file={file} />
|
||||
|
|
@ -101,20 +111,28 @@ export const FileSectionDiff = ({
|
|||
);
|
||||
}
|
||||
|
||||
const originalForDiff = file.isNewFile ? '' : (resolvedOriginal ?? '');
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
{isMissingOnDisk && (
|
||||
<div className="border-b border-border bg-red-500/10 px-4 py-2 text-xs text-red-200">
|
||||
File is missing on disk. This diff may be only a preview from agent logs. Use{' '}
|
||||
<span className="font-medium text-red-100">Restore</span> to create the file on disk.
|
||||
</div>
|
||||
)}
|
||||
<DiffErrorBoundary
|
||||
filePath={file.filePath}
|
||||
oldString={fileContent.originalFullContent ?? ''}
|
||||
oldString={originalForDiff}
|
||||
newString={resolvedModified}
|
||||
>
|
||||
<CodeMirrorDiffView
|
||||
key={`${file.filePath}:${discardCounter}`}
|
||||
original={fileContent.originalFullContent ?? ''}
|
||||
original={originalForDiff}
|
||||
modified={resolvedModified}
|
||||
fileName={file.relativePath}
|
||||
readOnly={false}
|
||||
showMergeControls={true}
|
||||
showMergeControls={!isMissingOnDisk}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
usePortionCollapse={true}
|
||||
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
|
||||
|
|
@ -122,6 +140,11 @@ export const FileSectionDiff = ({
|
|||
onContentChanged={(content) => onContentChanged(file.filePath, content)}
|
||||
editorViewRef={localEditorViewRef}
|
||||
onViewChange={handleViewChange}
|
||||
onSelectionChange={
|
||||
onSelectionChange
|
||||
? (info) => onSelectionChange(info ? { ...info, filePath: file.filePath } : null)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</DiffErrorBoundary>
|
||||
<div ref={sentinelRef} className="h-1 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { ChevronDown, ChevronRight, Loader2, Save, Undo2 } from 'lucide-react';
|
||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||
import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react';
|
||||
|
||||
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
|
@ -11,7 +12,7 @@ const CONTENT_SOURCE_LABELS: Record<string, string> = {
|
|||
'snippet-reconstruction': 'Reconstructed',
|
||||
'disk-current': 'Current Disk',
|
||||
'git-fallback': 'Git Fallback',
|
||||
unavailable: 'Unavailable',
|
||||
unavailable: 'Missing on disk',
|
||||
};
|
||||
|
||||
interface FileSectionHeaderProps {
|
||||
|
|
@ -24,6 +25,7 @@ interface FileSectionHeaderProps {
|
|||
onToggleCollapse: (filePath: string) => void;
|
||||
onDiscard: (filePath: string) => void;
|
||||
onSave: (filePath: string) => void;
|
||||
onRestoreMissingFile?: (filePath: string, content: string) => void;
|
||||
}
|
||||
|
||||
export const FileSectionHeader = ({
|
||||
|
|
@ -36,7 +38,21 @@ export const FileSectionHeader = ({
|
|||
onToggleCollapse,
|
||||
onDiscard,
|
||||
onSave,
|
||||
onRestoreMissingFile,
|
||||
}: FileSectionHeaderProps): React.ReactElement => {
|
||||
const isMissingOnDisk = fileContent?.contentSource === 'unavailable';
|
||||
const restoreContent =
|
||||
fileContent?.modifiedFullContent ??
|
||||
(() => {
|
||||
const writeSnippets = file.snippets.filter(
|
||||
(s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update')
|
||||
);
|
||||
if (writeSnippets.length === 0) return null;
|
||||
return writeSnippets[writeSnippets.length - 1].newString;
|
||||
})();
|
||||
const canRestore =
|
||||
!!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent != null;
|
||||
|
||||
const handleHeaderClick = (e: React.MouseEvent): void => {
|
||||
// Don't collapse when clicking action buttons
|
||||
if ((e.target as HTMLElement).closest('[data-no-collapse]')) return;
|
||||
|
|
@ -70,9 +86,44 @@ export const FileSectionHeader = ({
|
|||
)}
|
||||
|
||||
{fileContent?.contentSource && (
|
||||
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-text-muted">
|
||||
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={[
|
||||
'rounded px-1.5 py-0.5 text-[10px]',
|
||||
fileContent.contentSource === 'unavailable'
|
||||
? 'bg-red-500/20 text-red-300'
|
||||
: 'bg-surface-raised text-text-muted',
|
||||
].join(' ')}
|
||||
>
|
||||
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{fileContent.contentSource === 'unavailable' ? (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-text">File is missing on disk</div>
|
||||
<div className="text-text-muted">
|
||||
We can still show a preview from agent logs, but your filesystem is out of sync.
|
||||
</div>
|
||||
{restoreContent != null ? (
|
||||
<div className="text-text-muted">
|
||||
Use <span className="font-medium text-text">Restore</span> to write the preview
|
||||
content back to disk.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text-muted">
|
||||
Full file content is not available to restore automatically.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span>
|
||||
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
|
||||
</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{fileDecision && (
|
||||
|
|
@ -90,6 +141,23 @@ export const FileSectionHeader = ({
|
|||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
|
||||
{canRestore && restoreContent != null && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onRestoreMissingFile?.(file.filePath, restoreContent)}
|
||||
disabled={applying}
|
||||
className="flex items-center gap-1 rounded bg-blue-500/15 px-2 py-1 text-xs text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
|
||||
>
|
||||
<FilePlus className="size-3" />
|
||||
Restore
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Create/restore this file on disk from the preview
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasEdits && (
|
||||
<>
|
||||
<Tooltip>
|
||||
|
|
@ -122,7 +190,7 @@ export const FileSectionHeader = ({
|
|||
<TooltipContent side="bottom">
|
||||
<span>Save file to disk</span>
|
||||
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
|
||||
⌘↵
|
||||
{shortcutLabel('⌘ S', 'Ctrl+S')}
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import { IS_MAC } from '@renderer/utils/platformKeys';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface KeyboardShortcutsHelpProps {
|
||||
|
|
@ -7,16 +8,20 @@ interface KeyboardShortcutsHelpProps {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const mod = IS_MAC ? '\u2318' : 'Ctrl';
|
||||
const alt = IS_MAC ? '\u2325' : 'Alt';
|
||||
const shift = IS_MAC ? '\u21E7' : 'Shift';
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['\u2325+J'], action: 'Next change' },
|
||||
{ keys: ['\u2325+K'], action: 'Previous change' },
|
||||
{ keys: ['\u2325+\u2193'], action: 'Next file' },
|
||||
{ keys: ['\u2325+\u2191'], action: 'Previous file' },
|
||||
{ keys: ['\u2318+Y'], action: 'Accept change' },
|
||||
{ keys: ['\u2318+N'], action: 'Reject change' },
|
||||
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
|
||||
{ keys: ['\u2318+Z'], action: 'Undo' },
|
||||
{ keys: ['\u2318+\u21E7+Z'], action: 'Redo' },
|
||||
{ keys: [`${alt}+J`], action: 'Next change' },
|
||||
{ keys: [`${alt}+K`], action: 'Previous change' },
|
||||
{ keys: [`${alt}+\u2193`], action: 'Next file' },
|
||||
{ keys: [`${alt}+\u2191`], action: 'Previous file' },
|
||||
{ keys: [`${mod}+Y`], action: 'Accept change' },
|
||||
{ keys: [`${mod}+N`], action: 'Reject change' },
|
||||
{ keys: [`${mod}+S`], action: 'Save file' },
|
||||
{ keys: [`${mod}+Z`], action: 'Undo' },
|
||||
{ keys: [`${mod}+${shift}+Z`], action: 'Redo' },
|
||||
{ keys: ['?'], action: 'Toggle shortcuts' },
|
||||
{ keys: ['Esc'], action: 'Close dialog' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,25 +1,80 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||
import { diffLines } from 'diff';
|
||||
|
||||
import type { FileChangeSummary, SnippetDiff } from '@shared/types/review';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface ReviewDiffContentProps {
|
||||
file: FileChangeSummary;
|
||||
}
|
||||
|
||||
const SnippetDiffView = ({ snippet, index }: { snippet: SnippetDiff; index: number }) => {
|
||||
const diffResult = useMemo(() => {
|
||||
if (snippet.type === 'write-new') {
|
||||
// Весь файл — новый
|
||||
return diffLines('', snippet.newString);
|
||||
interface DiffLine {
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
html: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/** Build highlighted diff lines by mapping diff parts onto pre-highlighted old/new lines. */
|
||||
function buildHighlightedDiffLines(snippet: SnippetDiff, fileName: string): DiffLine[] {
|
||||
const isFullNew = snippet.type === 'write-new' || snippet.type === 'write-update';
|
||||
const oldCode = isFullNew ? '' : snippet.oldString;
|
||||
const diffResult = diffLines(oldCode, snippet.newString);
|
||||
|
||||
const oldHighlighted = highlightLines(oldCode, fileName);
|
||||
const newHighlighted = highlightLines(snippet.newString, fileName);
|
||||
|
||||
const result: DiffLine[] = [];
|
||||
let oldIdx = 0;
|
||||
let newIdx = 0;
|
||||
|
||||
for (const part of diffResult) {
|
||||
const lineCount = part.value.replace(/\n$/, '').split('\n').length;
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
if (part.removed) {
|
||||
result.push({
|
||||
type: 'removed',
|
||||
html: oldHighlighted[oldIdx++] ?? '',
|
||||
});
|
||||
} else if (part.added) {
|
||||
result.push({
|
||||
type: 'added',
|
||||
html: newHighlighted[newIdx++] ?? '',
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: 'unchanged',
|
||||
html: oldHighlighted[oldIdx++] ?? '',
|
||||
});
|
||||
newIdx++;
|
||||
}
|
||||
}
|
||||
if (snippet.type === 'write-update') {
|
||||
// Полная перезапись
|
||||
return diffLines('', snippet.newString);
|
||||
}
|
||||
return diffLines(snippet.oldString, snippet.newString);
|
||||
}, [snippet]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SnippetDiffView
|
||||
// =============================================================================
|
||||
|
||||
const SnippetDiffView = ({
|
||||
snippet,
|
||||
index,
|
||||
fileName,
|
||||
}: {
|
||||
snippet: SnippetDiff;
|
||||
index: number;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const lines = useMemo(() => buildHighlightedDiffLines(snippet, fileName), [snippet, fileName]);
|
||||
|
||||
const toolLabel =
|
||||
snippet.type === 'write-new'
|
||||
|
|
@ -32,7 +87,7 @@ const SnippetDiffView = ({ snippet, index }: { snippet: SnippetDiff; index: numb
|
|||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{/* Заголовок snippet */}
|
||||
{/* Snippet header */}
|
||||
<div className="flex items-center justify-between border-b border-border bg-surface-raised px-3 py-1.5">
|
||||
<span className="text-xs text-text-muted">
|
||||
#{index + 1} {toolLabel}
|
||||
|
|
@ -42,61 +97,54 @@ const SnippetDiffView = ({ snippet, index }: { snippet: SnippetDiff; index: numb
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Строки диффа */}
|
||||
{/* Diff lines with syntax highlighting (hljs HTML — safe, all input is escaped) */}
|
||||
<div className="overflow-x-auto font-mono text-xs leading-5">
|
||||
{diffResult.map((part, i) => {
|
||||
const lines = part.value.replace(/\n$/, '').split('\n');
|
||||
return lines.map((line, j) => {
|
||||
let bgClass = '';
|
||||
let prefix = ' ';
|
||||
let textClass = 'text-text-secondary';
|
||||
{lines.map((line, i) => {
|
||||
let bgClass = '';
|
||||
let prefix = ' ';
|
||||
|
||||
if (part.added) {
|
||||
bgClass = 'bg-[var(--diff-added-bg,rgba(46,160,67,0.15))]';
|
||||
prefix = '+';
|
||||
textClass = 'text-[var(--diff-added-text,#3fb950)]';
|
||||
} else if (part.removed) {
|
||||
bgClass = 'bg-[var(--diff-removed-bg,rgba(248,81,73,0.15))]';
|
||||
prefix = '-';
|
||||
textClass = 'text-[var(--diff-removed-text,#f85149)]';
|
||||
}
|
||||
if (line.type === 'added') {
|
||||
bgClass = 'bg-[var(--diff-added-bg,rgba(46,160,67,0.15))]';
|
||||
prefix = '+';
|
||||
} else if (line.type === 'removed') {
|
||||
bgClass = 'bg-[var(--diff-removed-bg,rgba(248,81,73,0.15))]';
|
||||
prefix = '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${i}-${j}`} className={`px-3 ${bgClass} ${textClass}`}>
|
||||
<span className="inline-block w-4 select-none text-text-muted opacity-50">
|
||||
{prefix}
|
||||
</span>
|
||||
<span className="whitespace-pre">{line}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div key={i} className={`flex px-3 ${bgClass}`}>
|
||||
<span className="inline-block w-4 shrink-0 select-none text-text-muted opacity-50">
|
||||
{prefix}
|
||||
</span>
|
||||
{/* highlight.js escapes all input text — only produces <span class="hljs-*"> tags */}
|
||||
<span
|
||||
className="whitespace-pre text-text-secondary"
|
||||
dangerouslySetInnerHTML={{ __html: line.html }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ReviewDiffContent
|
||||
// =============================================================================
|
||||
|
||||
export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => {
|
||||
const nonErrorSnippets = useMemo(() => file.snippets.filter((s) => !s.isError), [file.snippets]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* Заголовок файла */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text">{file.relativePath}</span>
|
||||
{file.isNewFile && (
|
||||
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{nonErrorSnippets.length} change{nonErrorSnippets.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Snippets */}
|
||||
{nonErrorSnippets.map((snippet, index) => (
|
||||
<SnippetDiffView key={snippet.toolUseId} snippet={snippet} index={index} />
|
||||
<SnippetDiffView
|
||||
key={snippet.toolUseId}
|
||||
snippet={snippet}
|
||||
index={index}
|
||||
fileName={file.relativePath}
|
||||
/>
|
||||
))}
|
||||
|
||||
{nonErrorSnippets.length === 0 && (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
|
@ -11,7 +12,6 @@ import {
|
|||
Circle,
|
||||
CircleDot,
|
||||
Eye,
|
||||
File,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
X as XIcon,
|
||||
|
|
@ -139,7 +139,7 @@ const TreeItem = ({
|
|||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
<FileStatusIcon status={status} />
|
||||
<File className="size-3.5 shrink-0" />
|
||||
<FileIcon fileName={node.name} className="size-3.5" />
|
||||
{viewedSet && viewedSet.has(node.data.filePath) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -281,29 +281,53 @@ const portionCollapseTheme = EditorView.theme({
|
|||
// CM recognizes it's the same field → keeps accumulated state (expanded regions) → no create() call.
|
||||
// Config is read from portionCollapseConfigFacet (controlled by compartment).
|
||||
|
||||
const portionCollapseField = StateField.define<DecorationSet>({
|
||||
create(state: EditorState): DecorationSet {
|
||||
interface PortionCollapseState {
|
||||
decorations: DecorationSet;
|
||||
/**
|
||||
* Once the user expands everything (i.e. removes the last collapse widget),
|
||||
* we should NOT re-initialize collapse on harmless transactions like cursor moves.
|
||||
*/
|
||||
userExpandedAll: boolean;
|
||||
}
|
||||
|
||||
const portionCollapseField = StateField.define<PortionCollapseState>({
|
||||
create(state: EditorState): PortionCollapseState {
|
||||
const cfg = state.facet(portionCollapseConfigFacet);
|
||||
return buildPortionRanges(state, cfg.margin, cfg.minSize, cfg.portionSize);
|
||||
return {
|
||||
decorations: buildPortionRanges(state, cfg.margin, cfg.minSize, cfg.portionSize),
|
||||
userExpandedAll: false,
|
||||
};
|
||||
},
|
||||
|
||||
update(deco: DecorationSet, tr: Transaction): DecorationSet {
|
||||
update(value: PortionCollapseState, tr: Transaction): PortionCollapseState {
|
||||
const cfg = tr.state.facet(portionCollapseConfigFacet);
|
||||
|
||||
// 1. Expand effects
|
||||
let result = deco;
|
||||
let nextDeco = value.decorations;
|
||||
let userExpandedAll = value.userExpandedAll;
|
||||
let hasExpandEffect = false;
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(expandPortion)) {
|
||||
hasExpandEffect = true;
|
||||
result = handleExpandPortion(result, effect.value, tr.state, cfg.minSize, cfg.portionSize);
|
||||
nextDeco = handleExpandPortion(
|
||||
nextDeco,
|
||||
effect.value,
|
||||
tr.state,
|
||||
cfg.minSize,
|
||||
cfg.portionSize
|
||||
);
|
||||
}
|
||||
if (effect.is(expandAllAtPos)) {
|
||||
hasExpandEffect = true;
|
||||
result = handleExpandAll(result, effect.value);
|
||||
nextDeco = handleExpandAll(nextDeco, effect.value);
|
||||
}
|
||||
}
|
||||
if (hasExpandEffect) return result;
|
||||
if (hasExpandEffect) {
|
||||
if (nextDeco === Decoration.none) {
|
||||
userExpandedAll = true;
|
||||
}
|
||||
return { decorations: nextDeco, userExpandedAll };
|
||||
}
|
||||
|
||||
// 2. Accept chunk (updateOriginalDoc) — editor doc unchanged, keep decorations.
|
||||
// Full rebuild here would destroy user's expanded state (Expand All / Expand N).
|
||||
|
|
@ -311,27 +335,47 @@ const portionCollapseField = StateField.define<DecorationSet>({
|
|||
// When mirrorEditsAfterResolve adds updateOriginalDoc to a docChanged transaction,
|
||||
// we must NOT short-circuit — fall through to docChanged handler for proper rebuild.
|
||||
if (tr.effects.some((e) => e.is(updateOriginalDoc)) && !tr.docChanged) {
|
||||
return deco;
|
||||
return value;
|
||||
}
|
||||
|
||||
// 3. Document changed (reject, user edit) → full rebuild
|
||||
// 3. Document changed (reject, user edit)
|
||||
//
|
||||
// Rebuilding from scratch here causes a bad UX: after the user expands (or when a hunk is
|
||||
// applied) the editor can suddenly re-collapse unchanged regions, hiding the code the user
|
||||
// was actively looking at. Instead, keep the user's current collapsed/expanded state stable
|
||||
// by mapping existing decorations through the document changes.
|
||||
if (tr.docChanged) {
|
||||
return buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize);
|
||||
const mapped = value.decorations.map(tr.changes);
|
||||
// If we previously had no collapse decoration but chunks are now available, initialize once.
|
||||
// BUT: if the user explicitly expanded everything, never re-collapse automatically.
|
||||
if (!value.userExpandedAll && value.decorations === Decoration.none) {
|
||||
const chunks = getChunks(tr.state);
|
||||
if (chunks) {
|
||||
return {
|
||||
decorations: buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize),
|
||||
userExpandedAll: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { decorations: mapped, userExpandedAll: value.userExpandedAll };
|
||||
}
|
||||
|
||||
// 4. Lazy init
|
||||
if (deco === Decoration.none) {
|
||||
if (!value.userExpandedAll && value.decorations === Decoration.none) {
|
||||
const chunks = getChunks(tr.state);
|
||||
if (chunks) {
|
||||
return buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize);
|
||||
return {
|
||||
decorations: buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize),
|
||||
userExpandedAll: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return deco;
|
||||
return value;
|
||||
},
|
||||
|
||||
provide(f) {
|
||||
return EditorView.decorations.from(f);
|
||||
return EditorView.decorations.from(f, (v) => v.decorations);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue