From 256ae1ef0b91ada7d06ffd454ebcbc26aa71dcac Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:31:40 -0700 Subject: [PATCH] Clean up long renderer JSX files --- .../automations/AutomationEditorDialog.tsx | 122 +- .../AutomationEditorDialogHeader.tsx | 134 ++ .../dashboard/DashboardAgentRow.tsx | 132 +- .../dashboard/DashboardAgentRowMessage.tsx | 50 + .../dashboard/DashboardAgentRowToolStep.tsx | 61 + .../editor/CombinedDiffFileTree.tsx | 140 +- .../src/components/editor/DiffSectionBody.tsx | 168 ++ .../src/components/editor/DiffSectionItem.tsx | 176 +- .../editor/RichMarkdownAnnotationOverlay.tsx | 65 + .../components/editor/RichMarkdownEditor.tsx | 1969 ++--------------- .../editor/RichMarkdownEditorSurface.tsx | 292 +++ .../editor/RichMarkdownReviewNoteLayer.tsx | 127 ++ .../editor/RichMarkdownReviewRailActions.tsx | 60 + .../editor/RichMarkdownSlashMenu.tsx | 4 +- .../editor/combined-diff-file-tree-row.tsx | 132 ++ .../editor/markdown-round-trip.test.ts | 4 +- ...h-markdown-annotation-submit-highlights.ts | 39 + .../editor/rich-markdown-commands.test.ts | 2 +- .../editor/rich-markdown-commands.tsx | 415 +--- .../rich-markdown-context-command-routing.ts | 83 + .../rich-markdown-editor-click-routing.ts | 208 ++ .../editor/rich-markdown-key-handler.ts | 6 +- .../rich-markdown-review-annotations.ts | 268 +++ .../rich-markdown-review-note-positioning.ts | 65 + .../rich-markdown-review-text-ranges.ts | 96 + .../editor/rich-markdown-slash-commands.tsx | 414 ++++ .../editor/useDiffSectionLayoutMetrics.ts | 51 + .../editor/useRichMarkdownEditorInstance.ts | 290 +++ .../editor/useRichMarkdownMenuController.ts | 154 ++ .../editor/useRichMarkdownProgrammaticSync.ts | 156 ++ .../editor/useRichMarkdownReviewController.ts | 280 +++ .../useRichMarkdownReviewCopyFeedback.ts | 81 + .../editor/useRichMarkdownReviewData.ts | 73 + .../useRichMarkdownReviewEditorEffects.ts | 56 + .../useRichMarkdownReviewRailController.ts | 261 +++ .../feature-wall/FeatureWallTourSurface.tsx | 53 +- .../SetupScriptAnimatedVisual.tsx | 116 +- .../SetupScriptNewWorkspaceModal.tsx | 72 + .../SetupScriptWorkspaceListCard.tsx | 54 + ...use-feature-wall-tour-keyboard-shortcut.ts | 27 + .../use-feature-wall-tour-rail-keydown.ts | 41 + .../src/components/mobile/MobilePage.tsx | 55 +- .../components/mobile/MobilePageToolbar.tsx | 45 + .../components/mobile/mobile-platform-copy.ts | 17 + .../right-sidebar/CreatePullRequestDialog.tsx | 50 +- .../CreatePullRequestGenerateButton.tsx | 64 + .../right-sidebar/HostedReviewActions.tsx | 59 +- .../right-sidebar/gitlab-mr-merge-state.ts | 60 + .../AutoRenameBranchFromWorkSetting.tsx | 85 +- .../settings/AutoRenameBranchPromptEditor.tsx | 89 + .../settings/BrowserUseComputerUseNotice.tsx | 33 + .../settings/BrowserUseEnableSwitch.tsx | 25 + .../components/settings/BrowserUsePane.tsx | 56 +- .../settings/ManageSessionKillDialog.tsx | 78 + .../settings/ManageSessionsSection.tsx | 109 +- .../settings/NotificationSettingToggle.tsx | 48 + .../components/settings/NotificationsPane.tsx | 58 +- .../RuntimePairingGeneratedUrlRows.tsx | 55 + .../settings/RuntimePairingUrlGenerator.tsx | 58 +- .../settings/TerminalAppearanceSection.tsx | 56 +- .../settings/TerminalFontSizeSetting.tsx | 67 + .../src/components/sidebar/AddRepoDialog.tsx | 1478 +++---------- .../sidebar/AddRepoDialogStepContent.tsx | 252 +++ .../sidebar/AddRepoNestedImportStep.tsx | 135 ++ .../components/sidebar/AddRepoRemoteStep.tsx | 151 ++ .../components/sidebar/AddRepoStartSteps.tsx | 253 +++ .../sidebar/AddRepoStepIndicator.tsx | 59 + .../src/components/sidebar/AddRepoSteps.tsx | 152 +- .../components/sidebar/WorktreeCardMeta.tsx | 90 +- .../sidebar/WorktreeCardMetadataControls.tsx | 86 + .../sidebar/add-repo-dialog-types.ts | 15 + .../components/sidebar/useAddRepoCloneFlow.ts | 181 ++ .../sidebar/useAddRepoLocalFolderFlow.ts | 207 ++ .../sidebar/useAddRepoNestedImportFlow.ts | 220 ++ .../sidebar/useAddRepoNestedReviewState.ts | 131 ++ .../sidebar/useAddRepoServerPathFlow.ts | 188 ++ .../sidebar/useAddRepoSetupActions.ts | 217 ++ .../components/stats/UsageOverviewPane.tsx | 222 +- .../stats/usage-overview-sections.tsx | 212 ++ .../status-bar/PortsStatusSegment.tsx | 257 +-- .../status-bar/ports-status-popover-rows.tsx | 245 ++ .../src/components/tab-bar/EditorFileTab.tsx | 183 +- .../tab-bar/EditorFileTabContextMenu.tsx | 204 ++ src/renderer/src/lib/agent-catalog.tsx | 157 +- src/renderer/src/lib/agent-icon-glyphs.tsx | 145 ++ 85 files changed, 7968 insertions(+), 5636 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx create mode 100644 src/renderer/src/components/dashboard/DashboardAgentRowMessage.tsx create mode 100644 src/renderer/src/components/dashboard/DashboardAgentRowToolStep.tsx create mode 100644 src/renderer/src/components/editor/DiffSectionBody.tsx create mode 100644 src/renderer/src/components/editor/RichMarkdownAnnotationOverlay.tsx create mode 100644 src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx create mode 100644 src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx create mode 100644 src/renderer/src/components/editor/RichMarkdownReviewRailActions.tsx create mode 100644 src/renderer/src/components/editor/combined-diff-file-tree-row.tsx create mode 100644 src/renderer/src/components/editor/rich-markdown-annotation-submit-highlights.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-context-command-routing.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-review-annotations.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-review-text-ranges.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-slash-commands.tsx create mode 100644 src/renderer/src/components/editor/useDiffSectionLayoutMetrics.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownMenuController.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownReviewController.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownReviewCopyFeedback.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownReviewData.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownReviewEditorEffects.ts create mode 100644 src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts create mode 100644 src/renderer/src/components/feature-wall/SetupScriptNewWorkspaceModal.tsx create mode 100644 src/renderer/src/components/feature-wall/SetupScriptWorkspaceListCard.tsx create mode 100644 src/renderer/src/components/feature-wall/use-feature-wall-tour-keyboard-shortcut.ts create mode 100644 src/renderer/src/components/feature-wall/use-feature-wall-tour-rail-keydown.ts create mode 100644 src/renderer/src/components/mobile/MobilePageToolbar.tsx create mode 100644 src/renderer/src/components/mobile/mobile-platform-copy.ts create mode 100644 src/renderer/src/components/right-sidebar/CreatePullRequestGenerateButton.tsx create mode 100644 src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.ts create mode 100644 src/renderer/src/components/settings/AutoRenameBranchPromptEditor.tsx create mode 100644 src/renderer/src/components/settings/BrowserUseComputerUseNotice.tsx create mode 100644 src/renderer/src/components/settings/BrowserUseEnableSwitch.tsx create mode 100644 src/renderer/src/components/settings/ManageSessionKillDialog.tsx create mode 100644 src/renderer/src/components/settings/NotificationSettingToggle.tsx create mode 100644 src/renderer/src/components/settings/RuntimePairingGeneratedUrlRows.tsx create mode 100644 src/renderer/src/components/settings/TerminalFontSizeSetting.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoStartSteps.tsx create mode 100644 src/renderer/src/components/sidebar/AddRepoStepIndicator.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardMetadataControls.tsx create mode 100644 src/renderer/src/components/sidebar/add-repo-dialog-types.ts create mode 100644 src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts create mode 100644 src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts create mode 100644 src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts create mode 100644 src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts create mode 100644 src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts create mode 100644 src/renderer/src/components/sidebar/useAddRepoSetupActions.ts create mode 100644 src/renderer/src/components/stats/usage-overview-sections.tsx create mode 100644 src/renderer/src/components/status-bar/ports-status-popover-rows.tsx create mode 100644 src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx create mode 100644 src/renderer/src/lib/agent-icon-glyphs.tsx diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index 222f048665b..de2d425e93d 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -1,11 +1,7 @@ -/* eslint-disable max-lines -- Why: the automation editor keeps its form fields - colocated so create/edit draft preservation rules stay reviewable. */ import React from 'react' -import { Info, Plus, Sparkles } from 'lucide-react' +import { Info, Plus } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Dialog, DialogContent } from '@/components/ui/dialog' import { Select, SelectContent, @@ -29,6 +25,7 @@ import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' import { Field } from './automation-page-parts' +import { AutomationEditorDialogHeader } from './AutomationEditorDialogHeader' import { AutomationSchedulePicker } from './AutomationSchedulePicker' import { AutomationSessionField } from './AutomationSessionField' import { AUTOMATION_TEMPLATES, type AutomationTemplate } from './automation-templates' @@ -81,28 +78,6 @@ type AutomationEditorDialogProps = { onSave: () => void } -function AutomationTemplateCard({ - template, - onSelect -}: { - template: AutomationTemplate - onSelect: () => void -}): React.JSX.Element { - return ( - - ) -} - export function AutomationEditorDialog({ open, isEditing, @@ -144,78 +119,25 @@ export function AutomationEditorDialog({ event.preventDefault() }} > - -
-
- - {isEditing - ? 'Edit automation' - : isEditingExternal - ? 'Edit Hermes automation' - : isHermesCreate - ? 'Create Hermes automation' - : 'Create automation'} - - - onDraftChange((current) => ({ ...current, name: event.target.value })) - } - /> -
- {isCreateMode ? ( -
- - value && onCreateTargetChange(value as AutomationCreateTarget) - } - variant="outline" - size="sm" - className="grid grid-cols-2" - > - - Orca - - - Hermes - - - - - - - -
- {AUTOMATION_TEMPLATES.map((template) => ( - { - onApplyTemplate(template) - setTemplateOpen(false) - }} - /> - ))} -
-
-
-
- ) : null} -
-
+ onDraftChange((current) => ({ ...current, name }))} + onTemplateOpenChange={setTemplateOpen} + onApplyTemplate={(template) => { + onApplyTemplate(template) + setTemplateOpen(false) + }} + />
{draft.scheduleWarning ? ( diff --git a/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx b/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx new file mode 100644 index 00000000000..fa512af056a --- /dev/null +++ b/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx @@ -0,0 +1,134 @@ +import { Sparkles } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' +import type { AutomationCreateTarget } from './AutomationEditorDialog' +import type { AutomationTemplate } from './automation-templates' + +type AutomationEditorDialogHeaderProps = { + isEditing: boolean + isEditingExternal: boolean + isHermesCreate: boolean + isCreateMode: boolean + createTarget: AutomationCreateTarget + draftName: string + templateOpen: boolean + templates: AutomationTemplate[] + modeToggleItemClassName: string + pickerTriggerClassName: string + onCreateTargetChange: (target: AutomationCreateTarget) => void + onDraftNameChange: (name: string) => void + onTemplateOpenChange: (open: boolean) => void + onApplyTemplate: (template: AutomationTemplate) => void +} + +function AutomationTemplateCard({ + template, + onSelect +}: { + template: AutomationTemplate + onSelect: () => void +}): React.JSX.Element { + return ( + + ) +} + +export function AutomationEditorDialogHeader({ + isEditing, + isEditingExternal, + isHermesCreate, + isCreateMode, + createTarget, + draftName, + templateOpen, + templates, + modeToggleItemClassName, + pickerTriggerClassName, + onCreateTargetChange, + onDraftNameChange, + onTemplateOpenChange, + onApplyTemplate +}: AutomationEditorDialogHeaderProps): React.JSX.Element { + return ( + +
+
+ + {isEditing + ? 'Edit automation' + : isEditingExternal + ? 'Edit Hermes automation' + : isHermesCreate + ? 'Create Hermes automation' + : 'Create automation'} + + onDraftNameChange(event.target.value)} + /> +
+ {isCreateMode ? ( +
+ + value && onCreateTargetChange(value as AutomationCreateTarget) + } + variant="outline" + size="sm" + className="grid grid-cols-2" + > + + Orca + + + Hermes + + + + + + + +
+ {templates.map((template) => ( + onApplyTemplate(template)} + /> + ))} +
+
+
+
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 13dc1a20acb..48ccbc262c6 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -1,13 +1,13 @@ -/* eslint-disable max-lines */ import React, { useState, useCallback } from 'react' -import { X, Wrench, ChevronDown, Send } from 'lucide-react' +import { X, ChevronDown, Send } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' +import { DashboardAgentRowMessage } from './DashboardAgentRowMessage' +import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' import type { AgentStatusState } from '../../../../shared/agent-status-types' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' @@ -529,121 +529,17 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ )}
- {/* Why: tool row and message row both carry different info — tool shows - the mechanical step (Bash: ...), message shows the agent's narration - ("let me verify the test ordering"). Antigravity can emit working - hooks without tool metadata between tool events, so the empty tool - slot must be a real line box instead of whitespace that can collapse. - Tool slot only reserves height while working, since done/blocked rows - shouldn't show a dangling wrench. */} - {isWorking && ( -
- {toolName ? ( - <> - {/* Why: header (wrench + tool name) stays on one line. When - collapsed, the input truncates inline next to the name. When - expanded, the input moves to its own block below so long - commands wrap to a consistent left margin instead of the - jagged shape that flex-wrapping produces. */} -
- - {toolName} - {!expanded && toolInput && ( - - {toolInput} - - )} -
- {/* Why: grid-rows [0fr]→[1fr] is the CSS-only height animation - pattern — outer grid track interpolates smoothly while the - inner min-h-0 + overflow-hidden clips content during the - transition. This avoids measuring heights in JS and still - animates unknown content sizes. */} - {toolInput && ( -
-
-                    {toolInput}
-                  
-
- )} - - ) : ( - - )} -
- )} - {/* Why: message slot is always reserved in collapsed view so the row - height stays fixed as lastAssistantMessage arrives/clears. The - expand animation lives on the CommentMarkdown itself (height + - interpolate-size) so the body reveals smoothly instead of snapping - open. When the message is empty we still render a placeholder in - the collapsed view to preserve the reserved line height. - - Interrupted gets its visible text on this secondary line, where the - agent response normally appears. That keeps the prompt line clean - while making the red status dot's meaning visible without hover. */} - {isInterrupted || lastAssistantMessage ? ( -
- {isInterrupted && ( - - interrupted - - )} - {lastAssistantMessage && ( - /'s preserved - // newlines don't break out of the truncation container. The - // `!` prefixes override CommentMarkdown's own layout styles so - // nothing (margins, list markers, block line-breaks) can push - // the preview onto a second line. - !expanded && - 'truncate whitespace-nowrap [&_*]:inline [&_*]:!whitespace-nowrap [&_*]:!m-0 [&_*]:!p-0 [&_ul]:list-none [&_ol]:list-none [&_br]:hidden' - )} - title={!expanded ? lastAssistantMessage : undefined} - /> - )} -
- ) : ( - !expanded && ( -
- ) - )} + + ) }) diff --git a/src/renderer/src/components/dashboard/DashboardAgentRowMessage.tsx b/src/renderer/src/components/dashboard/DashboardAgentRowMessage.tsx new file mode 100644 index 00000000000..b91ed686e47 --- /dev/null +++ b/src/renderer/src/components/dashboard/DashboardAgentRowMessage.tsx @@ -0,0 +1,50 @@ +import { cn } from '@/lib/utils' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' + +type DashboardAgentRowMessageProps = { + expanded: boolean + isInterrupted: boolean + lastAssistantMessage: string +} + +export function DashboardAgentRowMessage({ + expanded, + isInterrupted, + lastAssistantMessage +}: DashboardAgentRowMessageProps): React.JSX.Element | null { + // Why: message slot is always reserved in collapsed view so the row height + // stays fixed as assistant text arrives or clears. + if (!isInterrupted && !lastAssistantMessage) { + return expanded ? null : ( +
+ ) + } + + return ( +
+ {isInterrupted ? ( + + interrupted + + ) : null} + {lastAssistantMessage ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRowToolStep.tsx b/src/renderer/src/components/dashboard/DashboardAgentRowToolStep.tsx new file mode 100644 index 00000000000..1be38acde0e --- /dev/null +++ b/src/renderer/src/components/dashboard/DashboardAgentRowToolStep.tsx @@ -0,0 +1,61 @@ +import { Wrench } from 'lucide-react' +import { cn } from '@/lib/utils' + +type DashboardAgentRowToolStepProps = { + expanded: boolean + isWorking: boolean + toolName: string + toolInput: string +} + +export function DashboardAgentRowToolStep({ + expanded, + isWorking, + toolName, + toolInput +}: DashboardAgentRowToolStepProps): React.JSX.Element | null { + if (!isWorking) { + return null + } + + return ( +
+ {toolName ? ( + <> +
+ + {toolName} + {!expanded && toolInput ? ( + + {toolInput} + + ) : null} +
+ {toolInput ? ( +
+
+                {toolInput}
+              
+
+ ) : null} + + ) : ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index 68258a503c8..923dc89eea4 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -1,20 +1,6 @@ -/* eslint-disable max-lines -- Why: the combined diff tree keeps filtering, -directory rows, file rows, drag metadata, and navigation wiring together so -the row contracts stay local to the surface. */ import React from 'react' -import { - Check, - ChevronDown, - Filter, - Folder, - FolderOpen, - PanelLeftClose, - Search -} from 'lucide-react' -import { basename, dirname, joinPath } from '@/lib/path' +import { Check, Filter, PanelLeftClose, Search } from 'lucide-react' import { cn } from '@/lib/utils' -import { getFileTypeIcon } from '@/lib/file-type-icons' -import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -22,18 +8,10 @@ import { buildGitStatusSourceControlTree, buildSourceControlTree, compactSourceControlTree, - flattenSourceControlTree, - type SourceControlTreeNode + flattenSourceControlTree } from '@/components/right-sidebar/source-control-tree' -import { STATUS_COLORS, STATUS_LABELS } from '@/components/right-sidebar/status-display' -import type { - GitBranchChangeEntry, - GitFileStatus, - GitStagingArea, - GitStatusEntry -} from '../../../../shared/types' +import type { GitBranchChangeEntry, GitStagingArea, GitStatusEntry } from '../../../../shared/types' import { - getCombinedDiffFileTreeSectionKey, getEntryExtension, getFilteredCombinedDiffFileTreeEntries, isGitStatusEntry, @@ -41,6 +19,7 @@ import { type CombinedDiffFileTreeEntry, type CombinedDiffFileTreeMode } from './combined-diff-file-tree-model' +import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row' export { createCombinedDiffSectionIndexMap, @@ -49,14 +28,6 @@ export { handleCombinedDiffFileTreeNavigation } from './combined-diff-file-tree-model' -type CombinedDiffTreeNode = SourceControlTreeNode< - GitStatusEntry | GitBranchChangeEntry, - GitStagingArea | CombinedDiffBranchTreeArea -> - -const COMBINED_DIFF_TREE_INDENT_PX = 12 -const COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX = 8 -const COMBINED_DIFF_TREE_FILE_PADDING_PX = 20 const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked'] const UNCOMMITTED_AREA_LABELS: Record = { unstaged: 'Changes', @@ -325,106 +296,3 @@ export function CombinedDiffFileTree({ ) } - -function CombinedDiffFileTreeRow({ - node, - mode, - worktreePath, - activeSectionKey, - sectionIndexByKey, - isCollapsed, - onToggleDirectory, - onNavigate -}: { - node: CombinedDiffTreeNode - mode: CombinedDiffFileTreeMode - worktreePath: string - activeSectionKey: string | null - sectionIndexByKey: ReadonlyMap - isCollapsed: boolean - onToggleDirectory: (key: string) => void - onNavigate: (entry: CombinedDiffFileTreeEntry) => void -}): React.JSX.Element { - if (node.type === 'directory') { - return ( -
{ - event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path)) - event.dataTransfer.effectAllowed = 'copy' - }} - > - - - {node.fileCount} - -
- ) - } - - const sectionKey = getCombinedDiffFileTreeSectionKey(mode, node.entry) - const FileIcon = getFileTypeIcon(node.entry.path) - const fileName = basename(node.entry.path) - const parentDir = dirname(node.entry.path) - const dirPath = parentDir === '.' ? '' : parentDir - const status = node.entry.status as GitFileStatus - const disabled = !sectionIndexByKey.has(sectionKey) - - return ( - - ) -} diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx new file mode 100644 index 00000000000..5fe5e892dca --- /dev/null +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -0,0 +1,168 @@ +import { lazy, type RefObject } from 'react' +import { AlertCircle, RefreshCw } from 'lucide-react' +import { DiffEditor, type DiffOnMount } from '@monaco-editor/react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover' +import { combinedDiffSectionScrollbarOptions } from './diff-editor-scrollbar-options' +import type { DiffSection } from './diff-section-types' + +const ImageDiffViewer = lazy(() => import('./ImageDiffViewer')) + +type DiffSectionBodyProps = { + section: DiffSection + index: number + sectionBodyRef: RefObject + sectionBodyHeight: number | undefined + useIntrinsicImageHeight: boolean + popover: { + lineNumber: number + startLine?: number + top: number + left?: number + } | null + addLineCommentPlaceholder?: string + addLineCommentLabel?: string + isBranchMode: boolean + sideBySide: boolean + isDark: boolean + language: string + modelPathBase: string + isEditable: boolean + diffEditorFontSize: number + terminalFontFamily?: string + onCancelComment: () => void + onSubmitComment: (body: string) => Promise + onRetrySection: (index: number) => void + onMount: DiffOnMount +} + +export function DiffSectionBody({ + section, + index, + sectionBodyRef, + sectionBodyHeight, + useIntrinsicImageHeight, + popover, + addLineCommentPlaceholder, + addLineCommentLabel, + isBranchMode, + sideBySide, + isDark, + language, + modelPathBase, + isEditable, + diffEditorFontSize, + terminalFontFamily, + onCancelComment, + onSubmitComment, + onRetrySection, + onMount +}: DiffSectionBodyProps): React.JSX.Element { + return ( +
+ {popover ? ( + // Why: key by lineNumber so the popover remounts when the anchor + // line changes instead of leaking draft state across lines. + + ) : null} + {section.loading ? ( +
+ + Loading diff... +
+ ) : section.error ? ( +
+
+ + {section.error} +
+ +
+ ) : section.diffResult?.kind === 'binary' ? ( + section.diffResult.isImage ? ( + + ) : ( +
+
+
Binary file changed
+
+ {isBranchMode + ? 'Text diff is unavailable for this file in branch compare.' + : 'Text diff is unavailable for this file.'} +
+
+
+ ) + ) : ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index 0ea979639c1..a83af3ecbee 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -1,6 +1,4 @@ -/* eslint-disable max-lines -- Why: this component owns diff rendering, image previews, comment popovers, and expansion state as one synchronized editor row. */ import { - lazy, useCallback, useEffect, useMemo, @@ -9,8 +7,7 @@ import { type MutableRefObject, type ReactNode } from 'react' -import { AlertCircle, RefreshCw } from 'lucide-react' -import { DiffEditor, type DiffOnMount } from '@monaco-editor/react' +import type { DiffOnMount } from '@monaco-editor/react' import type { editor as monacoEditor } from 'monaco-editor' import { monaco } from '@/lib/monaco-setup' import { detectLanguage } from '@/lib/language-detect' @@ -21,24 +18,18 @@ import { useDiffCommentDecorator, type DecoratedDiffComment } from '../diff-comments/useDiffCommentDecorator' -import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover' import { getDiffCommentPopoverLeft, getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options' -import { computeLineStats } from './diff-line-stats' import { DiffSectionHeader } from './DiffSectionHeader' -import { getDiffSectionBodyHeight, isIntrinsicHeightImageDiff } from './diff-section-layout' import type { DiffSection } from './diff-section-types' import type { DiffComment } from '../../../../shared/types' -import { cn } from '@/lib/utils' import { isDiffComment } from '@/lib/diff-comment-compat' -import { Button } from '@/components/ui/button' import { installEditorSaveShortcut } from './editor-shortcuts' -import { combinedDiffSectionScrollbarOptions } from './diff-editor-scrollbar-options' - -const ImageDiffViewer = lazy(() => import('./ImageDiffViewer')) +import { DiffSectionBody } from './DiffSectionBody' +import { useDiffSectionLayoutMetrics } from './useDiffSectionLayoutMetrics' export function DiffSectionItem({ section, @@ -285,37 +276,9 @@ export function DiffSectionItem({ } } - const lineStats = useMemo( - () => - section.loading || section.error - ? null - : computeLineStats(section.originalContent, section.modifiedContent, section.status), - [ - section.error, - section.loading, - section.originalContent, - section.modifiedContent, - section.status - ] - ) - const changedLineCount = useMemo(() => { - if (lineStats) { - return lineStats.added + lineStats.removed - } - if (section.added === undefined && section.removed === undefined) { - return undefined - } - return (section.added ?? 0) + (section.removed ?? 0) - }, [lineStats, section.added, section.removed]) - // Why: image diffs need document-flow height in the combined view; the text - // fallback only knows line counts and would squash screenshots into one row. - const useIntrinsicImageHeight = isIntrinsicHeightImageDiff(section.diffResult) - const sectionBodyHeight = getDiffSectionBodyHeight({ - measuredContentHeight: sectionHeight, - originalContent: section.originalContent, - modifiedContent: section.modifiedContent, - changedLineCount, - useIntrinsicImageHeight + const { lineStats, sectionBodyHeight, useIntrinsicImageHeight } = useDiffSectionLayoutMetrics({ + section, + sectionHeight }) const handleMount: DiffOnMount = (editor, _monaco) => { @@ -445,111 +408,28 @@ export function DiffSectionItem({ /> {!section.collapsed && ( -
- {popover && ( - // Why: key by lineNumber so the popover remounts when the anchor - // line changes, resetting the internal draft body and textarea - // focus per anchor line instead of leaking state across lines. - setPopover(null)} - onSubmit={handleSubmitComment} - /> - )} - {section.loading ? ( -
- - Loading diff... -
- ) : section.error ? ( -
-
- - {section.error} -
- -
- ) : section.diffResult?.kind === 'binary' ? ( - section.diffResult.isImage ? ( - - ) : ( -
-
-
Binary file changed
-
- {isBranchMode - ? 'Text diff is unavailable for this file in branch compare.' - : 'Text diff is unavailable for this file.'} -
-
-
- ) - ) : ( - - )} -
+ setPopover(null)} + onSubmitComment={handleSubmitComment} + onRetrySection={retrySection} + onMount={handleMount} + /> )} ) diff --git a/src/renderer/src/components/editor/RichMarkdownAnnotationOverlay.tsx b/src/renderer/src/components/editor/RichMarkdownAnnotationOverlay.tsx new file mode 100644 index 00000000000..7354171c365 --- /dev/null +++ b/src/renderer/src/components/editor/RichMarkdownAnnotationOverlay.tsx @@ -0,0 +1,65 @@ +import { Plus } from 'lucide-react' +import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover' +import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations' + +type RichMarkdownAnnotationOverlayProps = { + target: RichMarkdownAnnotationTarget | null + popover: RichMarkdownAnnotationTarget | null + markdownSourceLineOffset: number + onOpenPopover: () => void + onCancelPopover: () => void + onSubmit: (body: string) => Promise +} + +export function RichMarkdownAnnotationOverlay({ + target, + popover, + markdownSourceLineOffset, + onOpenPopover, + onCancelPopover, + onSubmit +}: RichMarkdownAnnotationOverlayProps): React.JSX.Element { + return ( + <> + {target ? ( + + ) : null} + {popover ? ( + + ) : null} + + ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 853470ada38..7ba24e51df9 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,84 +1,25 @@ -/* eslint-disable max-lines -- Why: this component co-locates the rich markdown editor surface, toolbar, search, and slash menu so tightly coupled editor state stays in one place. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { EditorContent, useEditor } from '@tiptap/react' import type { Editor } from '@tiptap/react' -import type { JSONContent } from '@tiptap/core' import type { DiffComment, MarkdownDocument } from '../../../../shared/types' -import { RichMarkdownSlashMenu } from './RichMarkdownSlashMenu' -import { RichMarkdownDocLinkMenu } from './RichMarkdownDocLinkMenu' -import { RichMarkdownEmojiMenu } from './RichMarkdownEmojiMenu' import { useAppStore } from '@/store' -import { RichMarkdownToolbar } from './RichMarkdownToolbar' -import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import { useLocalImagePick } from './useLocalImagePick' -import { createRichMarkdownExtensions } from './rich-markdown-extensions' -import { getConnectionId } from '@/lib/connection-context' -import { slashCommands, syncDocLinkMenu, syncSlashMenu } from './rich-markdown-commands' -import type { - DocLinkMenuRow, - DocLinkMenuState, - SlashCommand, - SlashMenuState -} from './rich-markdown-commands' -import { getMarkdownDocCompletionDocuments } from './markdown-doc-completions' -import { RichMarkdownSearchBar } from './RichMarkdownSearchBar' import { useRichMarkdownSearch } from './useRichMarkdownSearch' -import { - getLinkBubblePosition, - RichMarkdownLinkBubble, - type LinkBubbleState -} from './RichMarkdownLinkBubble' +import type { LinkBubbleState } from './RichMarkdownLinkBubble' import { useLinkBubble } from './useLinkBubble' import { useEditorScrollRestore } from './useEditorScrollRestore' import { useModifierHeldClass } from './useModifierHeldClass' import { registerPendingEditorFlush } from './editor-pending-flush' -import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler' -import { normalizeSoftBreaks } from './rich-markdown-normalize' -import { autoFocusRichEditor } from './rich-markdown-auto-focus' -import { handleRichMarkdownCut } from './rich-markdown-cut-handler' -import { openHttpLink } from '@/lib/http-link-routing' -import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' -import { toast } from 'sonner' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' -import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation' -import { - absolutePathToFileUri as toFileUrlForOsEscape, - resolveMarkdownLinkTarget -} from './markdown-internal-links' -import { scrollToAnchorInEditor } from './markdown-anchor-scroll' -import type { - RichMarkdownContextMenuCommand, - RichMarkdownContextMenuCommandPayload -} from '../../../../shared/rich-markdown-context-menu' import { buildMarkdownTableOfContents, type MarkdownTocItem } from './markdown-table-of-contents' -import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel' -import { getRelativePathInsideRoot, normalizeRelativePath } from '@/lib/path' -import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover' -import { DiffCommentCard } from '../diff-comments/DiffCommentCard' -import { NotesSendMenu, type NotesSendMenuScope } from './NotesSendMenu' -import { isMarkdownComment } from '@/lib/diff-comment-compat' -import { Check, Copy, MessageSquare, Plus } from 'lucide-react' +import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface' +import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance' +import { useRichMarkdownMenuController } from './useRichMarkdownMenuController' +import { useRichMarkdownProgrammaticSync } from './useRichMarkdownProgrammaticSync' +import { useRichMarkdownReviewController } from './useRichMarkdownReviewController' +import { useRichMarkdownReviewEditorEffects } from './useRichMarkdownReviewEditorEffects' import { - formatMarkdownReviewNotes, - getMarkdownReviewCardQuote, - sortMarkdownReviewNotes, - type MarkdownReviewNote -} from '@/lib/markdown-review-notes' -import { copyMarkdownReviewNotesForAgent } from '@/lib/markdown-review-note-copy' -import { - richMarkdownAnnotationHighlightPluginKey, - type RichMarkdownAnnotationHighlightRange -} from './rich-markdown-annotation-highlight' -import { - getRichMarkdownLineRangeFromBlocks, - getRichMarkdownRangeBounds, - getRichMarkdownRangeStart -} from './rich-markdown-range-bounds' -import { - shouldExpandRichMarkdownReviewRail, - stackRichMarkdownReviewNotePositions, - type RichMarkdownReviewNotePosition -} from './rich-markdown-review-note-layout' + isRichMarkdownContextCommandTarget, + runRichMarkdownContextCommand +} from './rich-markdown-context-command-routing' type RichMarkdownEditorProps = { fileId: string @@ -104,455 +45,10 @@ type RichMarkdownEditorProps = { headerSlot?: React.ReactNode } -const richMarkdownExtensions = createRichMarkdownExtensions({ - includePlaceholder: true -}) - -function clampMenuSelectionIndex(index: number, itemCount: number): number { - if (itemCount <= 0) { - return 0 - } - return Math.min(Math.max(index, 0), itemCount - 1) -} - -function runRichMarkdownContextCommand( - command: RichMarkdownContextMenuCommand, - editor: Editor, - toggleLink: () => void, - pickImage: () => void -): void { - switch (command) { - case 'add-link': - toggleLink() - return - case 'bold': - editor.chain().focus().toggleBold().run() - return - case 'italic': - editor.chain().focus().toggleItalic().run() - return - case 'strike': - editor.chain().focus().toggleStrike().run() - return - case 'inline-code': - editor.chain().focus().toggleCode().run() - return - case 'code-block': - editor.chain().focus().toggleCodeBlock().run() - return - case 'blockquote': - editor.chain().focus().toggleBlockquote().run() - return - case 'paragraph': - editor.chain().focus().setParagraph().run() - return - case 'heading-1': - editor.chain().focus().setHeading({ level: 1 }).run() - return - case 'heading-2': - editor.chain().focus().setHeading({ level: 2 }).run() - return - case 'heading-3': - editor.chain().focus().setHeading({ level: 3 }).run() - return - case 'bullet-list': - editor.chain().focus().toggleBulletList().run() - return - case 'ordered-list': - editor.chain().focus().toggleOrderedList().run() - return - case 'task-list': - editor.chain().focus().toggleTaskList().run() - return - case 'image': - pickImage() - return - case 'divider': - editor.chain().focus().setHorizontalRule().run() - } -} - -function shouldFocusEmptyEditorFromSurfaceClick( - event: React.MouseEvent, - editor: Editor | null -): boolean { - if (!editor?.isEmpty || event.button !== 0) { - return false - } - const target = event.target - if (!(target instanceof Element)) { - return false - } - return !target.closest('.rich-markdown-editor-shell button, .rich-markdown-editor-shell input') -} - -function isRichMarkdownReviewNoteNavigationClick(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false - } - return !target.closest('button,input,textarea,select,a,[contenteditable="true"]') -} - -function isRichMarkdownContextCommandTarget( - payload: RichMarkdownContextMenuCommandPayload, - root: HTMLElement | null -): boolean { - if (!root) { - return false - } - const rect = root.getBoundingClientRect() - return ( - payload.x >= rect.left && - payload.x <= rect.right && - payload.y >= rect.top && - payload.y <= rect.bottom - ) -} - function flattenMarkdownTocItems(items: MarkdownTocItem[]): MarkdownTocItem[] { return items.flatMap((item) => [item, ...flattenMarkdownTocItems(item.children)]) } -type RichMarkdownCommentBlock = { - key: string - startLine: number - endLine: number - from: number - to: number -} - -type RichMarkdownComposerState = { - lineNumber: number - startLine?: number -} - -type RichMarkdownAnnotationTarget = RichMarkdownComposerState & { - from: number - to: number - selectedText: string - top: number - left?: number - buttonTop: number - buttonLeft: number -} - -function countMarkdownLines(value: string): number { - if (value.length === 0) { - return 1 - } - return value.split(/\r\n|\r|\n/).length -} - -function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { - return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() -} - -function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { - const jsonContent = editor.getJSON().content ?? [] - const blocks: RichMarkdownCommentBlock[] = [] - let nextLine = 1 - let previousNodeJson: JSONContent | null = null - let previousNodeLineCount = 0 - - editor.state.doc.forEach((node, nodeOffset, index) => { - const nodeJson = jsonContent[index] - if (!nodeJson) { - return - } - const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) - const nodeLineCount = countMarkdownLines(nodeMarkdown) - if (previousNodeJson) { - const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) - const separatorLineCount = Math.max( - 0, - countMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount - ) - nextLine += separatorLineCount - } - const startLine = nextLine - const endLine = Math.max(startLine, startLine + nodeLineCount - 1) - const from = nodeOffset + 1 - blocks.push({ - key: `${index}:${startLine}-${endLine}`, - startLine, - endLine, - from, - to: from + Math.max(0, node.nodeSize - 1) - }) - nextLine = endLine + 1 - previousNodeJson = nodeJson - previousNodeLineCount = nodeLineCount - }) - - if (blocks.length === 0) { - blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) - } - - return blocks -} - -function clampRichMarkdownAnnotationTarget( - editor: Editor, - target: RichMarkdownAnnotationTarget -): RichMarkdownAnnotationTarget | null { - const maxPos = Math.max(1, editor.state.doc.content.size) - const from = Math.max(1, Math.min(target.from, maxPos)) - const to = Math.max(1, Math.min(target.to, maxPos)) - const clampedFrom = Math.min(from, to) - const clampedTo = Math.max(from, to) - if (clampedFrom === clampedTo) { - return null - } - return { ...target, from: clampedFrom, to: clampedTo } -} - -function clearRichMarkdownNotePositions( - setNotePositions: React.Dispatch> -): void { - setNotePositions((current) => (current.length === 0 ? current : [])) -} - -type RichMarkdownTextChar = { - value: string - pos: number | null -} - -function normalizeRichMarkdownTextWithPositions( - chars: RichMarkdownTextChar[] -): RichMarkdownTextChar[] { - const normalized: RichMarkdownTextChar[] = [] - let previousWasWhitespace = false - for (const char of chars) { - if (/\s/.test(char.value)) { - if (!previousWasWhitespace) { - normalized.push({ value: ' ', pos: char.pos }) - } - previousWasWhitespace = true - continue - } - normalized.push(char) - previousWasWhitespace = false - } - return normalized -} - -function collectRichMarkdownTextChars( - editor: Editor, - from = 0, - to = editor.state.doc.content.size -): RichMarkdownTextChar[] { - const chars: RichMarkdownTextChar[] = [] - editor.state.doc.nodesBetween(from, to, (node, pos) => { - if (!node.isText || !node.text) { - return - } - if (chars.length > 0) { - chars.push({ value: ' ', pos: null }) - } - for (let index = 0; index < node.text.length; index += 1) { - chars.push({ value: node.text[index], pos: pos + index }) - } - }) - return chars -} - -function findRichMarkdownTextRanges( - chars: RichMarkdownTextChar[], - selectedText: string -): RichMarkdownAnnotationHighlightRange[] { - const normalizedChars = normalizeRichMarkdownTextWithPositions(chars) - const haystack = normalizedChars.map((char) => char.value).join('') - const needle = normalizeRichMarkdownTextWithPositions( - Array.from(selectedText).map((value) => ({ value, pos: null })) - ) - .map((char) => char.value) - .join('') - const start = haystack.indexOf(needle) - if (start === -1) { - return [] - } - - const positions = normalizedChars - .slice(start, start + needle.length) - .map((char) => char.pos) - .filter((pos): pos is number => pos !== null) - .sort((left, right) => left - right) - if (positions.length === 0) { - return [] - } - - const ranges: RichMarkdownAnnotationHighlightRange[] = [] - let from = positions[0] - let to = positions[0] + 1 - for (const pos of positions.slice(1)) { - if (pos === to) { - to += 1 - continue - } - ranges.push({ from, to }) - from = pos - to = pos + 1 - } - ranges.push({ from, to }) - return ranges -} - -function getRichMarkdownAnnotationHighlightRanges( - editor: Editor, - comments: readonly DiffComment[], - markdownSourceLineOffset: number -): RichMarkdownAnnotationHighlightRange[] { - return comments.flatMap((comment) => - getRichMarkdownAnnotationHighlightRangesForComment(editor, comment, markdownSourceLineOffset) - ) -} - -function getRichMarkdownAnnotationHighlightRangesForComment( - editor: Editor, - comment: DiffComment, - markdownSourceLineOffset: number -): RichMarkdownAnnotationHighlightRange[] { - const blocks = buildRichMarkdownCommentBlocks(editor) - const selectedText = comment.selectedText?.trim() - if (!selectedText) { - return [] - } - const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset) - const block = blocks.find( - (candidate) => candidate.startLine <= bodyLineNumber && bodyLineNumber <= candidate.endLine - ) - if (block) { - const blockRanges = findRichMarkdownTextRanges( - collectRichMarkdownTextChars(editor, block.from, block.to), - selectedText - ) - if (blockRanges.length > 0) { - return blockRanges - } - } - return findRichMarkdownTextRanges(collectRichMarkdownTextChars(editor), selectedText) -} - -function getRichMarkdownCommentAtPos( - editor: Editor, - comments: readonly DiffComment[], - markdownSourceLineOffset: number, - pos: number -): DiffComment | null { - return ( - comments.find((comment) => - getRichMarkdownAnnotationHighlightRangesForComment( - editor, - comment, - markdownSourceLineOffset - ).some((range) => range.from <= pos && pos <= range.to) - ) ?? null - ) -} - -function getRichMarkdownCommentAnchorTop( - editor: Editor, - comment: DiffComment, - block: RichMarkdownCommentBlock, - containerRect: DOMRect, - containerScrollTop: number, - markdownSourceLineOffset: number -): number | null { - try { - const ranges = getRichMarkdownAnnotationHighlightRangesForComment( - editor, - comment, - markdownSourceLineOffset - ) - // Why: range notes should sort by the start of the selected text. Anchoring - // to the end puts overlapping ranges with the same final line in creation - // order, so a 43-45 card can render above a 41-45 card. - const anchorPos = getRichMarkdownRangeStart(ranges) ?? block.from - const coords = editor.view.coordsAtPos( - Math.max(1, Math.min(anchorPos, editor.state.doc.content.size)) - ) - return coords.top - containerRect.top + containerScrollTop - } catch { - return null - } -} - -function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerState { - const blocks = buildRichMarkdownCommentBlocks(editor) - const { from, to, empty } = editor.state.selection - const selectedBlocks = empty - ? blocks.filter((block) => block.from <= from && from <= block.to) - : blocks.filter((block) => from <= block.to && to >= block.from) - const targetBlocks = selectedBlocks.length > 0 ? selectedBlocks : [blocks[0]!] - return getRichMarkdownLineRangeFromBlocks(targetBlocks) ?? { lineNumber: 1 } -} - -function hasRichMarkdownCommentForRange( - comments: readonly DiffComment[], - target: Pick, - markdownSourceLineOffset: number -): boolean { - const startLine = (target.startLine ?? target.lineNumber) + markdownSourceLineOffset - const endLine = target.lineNumber + markdownSourceLineOffset - const selectedText = target.selectedText.trim() - return comments.some((comment) => { - const commentStartLine = comment.startLine ?? comment.lineNumber - return ( - commentStartLine === startLine && - comment.lineNumber === endLine && - (comment.selectedText?.trim() ?? '') === selectedText - ) - }) -} - -function getCurrentRichMarkdownSelectionRect(root: HTMLElement): DOMRect | null { - const selection = window.getSelection() - if (!selection || selection.isCollapsed || selection.rangeCount === 0) { - return null - } - const range = selection.getRangeAt(0) - if (!root.contains(range.commonAncestorContainer)) { - return null - } - const rect = range.getBoundingClientRect() - if (rect.width > 0 || rect.height > 0) { - return rect - } - return Array.from(range.getClientRects()).find((candidate) => candidate.width > 0) ?? null -} - -function getRichMarkdownAnnotationTarget( - editor: Editor, - root: HTMLElement -): RichMarkdownAnnotationTarget | null { - if (editor.state.selection.empty) { - return null - } - const rect = getCurrentRichMarkdownSelectionRect(root) - if (!rect) { - return null - } - const selectedText = window.getSelection()?.toString().trim() ?? '' - if (!selectedText) { - return null - } - const rootRect = root.getBoundingClientRect() - const popoverWidth = 420 - const left = Math.max(56, rootRect.width - popoverWidth - 24) - const buttonTop = Math.max(8, rect.bottom - rootRect.top + 6) - const popoverTop = Math.max(8, Math.min(buttonTop + 28, rootRect.height - 220)) - return { - ...getRichMarkdownSelectionRange(editor), - from: editor.state.selection.from, - to: editor.state.selection.to, - selectedText, - top: popoverTop, - left, - buttonTop, - buttonLeft: Math.max(56, rootRect.width - 42) - } -} - export default function RichMarkdownEditor({ fileId, content, @@ -600,33 +96,14 @@ export default function RichMarkdownEditor({ return null }) const scrollContainerRef = useRef(null) - const [slashMenu, setSlashMenu] = useState(null) - const [slashSelection, setSlashSelection] = useState<{ query: string | null; index: number }>({ - query: null, - index: 0 - }) - const [docLinkMenu, setDocLinkMenu] = useState(null) - const [emojiMenu, setEmojiMenu] = useState<{ left: number; top: number } | null>(null) - const [docLinkSelection, setDocLinkSelection] = useState<{ query: string | null; index: number }>( - { - query: null, - index: 0 - } - ) + const menu = useRichMarkdownMenuController({ markdownDocuments }) const isMac = navigator.userAgent.includes('Mac') const lastCommittedMarkdownRef = useRef(content) - const slashMenuRef = useRef(null) - const filteredSlashCommandsRef = useRef(slashCommands) - const selectedCommandIndexRef = useRef(0) - const docLinkMenuRef = useRef(null) - const filteredDocLinkRowsRef = useRef([]) - const selectedDocLinkIndexRef = useRef(0) const onContentChangeRef = useRef(onContentChange) const onDirtyStateHintRef = useRef(onDirtyStateHint) const onSaveRef = useRef(onSave) const onOpenDocLinkRef = useRef(onOpenDocLink) const handleLocalImagePickRef = useRef<() => void>(() => {}) - const handleEmojiPickRef = useRef<(menu: SlashMenuState) => void>(() => {}) const openSearchRef = useRef<() => void>(() => {}) // Why: ProseMirror keeps the initial handleKeyDown closure, so `editor` stays // stuck at the first-render null value unless we read the live instance here. @@ -644,75 +121,22 @@ export default function RichMarkdownEditor({ const isApplyingProgrammaticUpdateRef = useRef(false) const [linkBubble, setLinkBubble] = useState(null) const [isEditingLink, setIsEditingLink] = useState(false) - const [annotationTarget, setAnnotationTarget] = useState( - null - ) - const [annotationPopover, setAnnotationPopover] = useState( - null - ) - const [reviewRailOpen, setReviewRailOpen] = useState(false) - const [reviewNotesCopied, setReviewNotesCopied] = useState(false) - const [copiedReviewNoteId, setCopiedReviewNoteId] = useState(null) - const [activeReviewCommentId, setActiveReviewCommentId] = useState(null) - const [attentionReviewCommentId, setAttentionReviewCommentId] = useState(null) - const [notePositions, setNotePositions] = useState([]) - const annotationPopoverRef = useRef(null) - const canAnnotateRichMarkdownRef = useRef(false) - const markdownCommentsRef = useRef([]) - const notePositionsRef = useRef([]) - const markdownSourceLineOffsetRef = useRef(markdownSourceLineOffset) - const attentionReviewCommentTimeoutRef = useRef(null) - const sourceAttentionTimeoutRef = useRef(null) - const reviewNotesCopiedResetTimerRef = useRef(null) - const copiedReviewNoteResetTimerRef = useRef(null) - const annotationTargetFrameRef = useRef(null) - const notePositionsFrameRef = useRef(null) const isEditingLinkRef = useRef(false) const typedEmptyOrderedListMarkerRef = useRef(false) - const sourceRelativePath = useMemo( - () => - markdownAnnotationFilePath - ? normalizeRelativePath(markdownAnnotationFilePath) - : getRelativePathInsideRoot(filePath, worktreeRoot), - [filePath, markdownAnnotationFilePath, worktreeRoot] - ) - const canAnnotateRichMarkdown = Boolean(markdownAnnotationsEnabled && sourceRelativePath !== null) - const markdownComments = useMemo( - () => - (allDiffComments ?? []).filter( - (comment) => comment.filePath === sourceRelativePath && isMarkdownComment(comment) - ), - [allDiffComments, sourceRelativePath] - ) - const markdownReviewNotes = useMemo( - () => sortMarkdownReviewNotes(markdownComments as MarkdownReviewNote[]), - [markdownComments] - ) - const unsentMarkdownReviewNotes = useMemo( - () => markdownReviewNotes.filter((note) => !note.sentAt), - [markdownReviewNotes] - ) - const unsentMarkdownReviewPrompt = useMemo( - () => formatMarkdownReviewNotes(unsentMarkdownReviewNotes, markdownReviewContent), - [markdownReviewContent, unsentMarkdownReviewNotes] - ) - const unsentMarkdownReviewScope = useMemo[]>( - () => [ - { - id: 'all', - label: 'All unsent notes', - notes: unsentMarkdownReviewNotes, - prompt: unsentMarkdownReviewPrompt - } - ], - [unsentMarkdownReviewNotes, unsentMarkdownReviewPrompt] - ) - const hasMarkdownComments = markdownComments.length > 0 - const reviewRailVisible = hasMarkdownComments && reviewRailOpen - const reviewRailExpanded = shouldExpandRichMarkdownReviewRail({ - hasReviewNotes: hasMarkdownComments, - reviewRailOpen, - hasDraftNote: annotationPopover !== null + const review = useRichMarkdownReviewController({ + addDiffComment, + allDiffComments, + content, + editorRef, + filePath, + markdownAnnotationFilePath, + markdownAnnotationsEnabled, + markdownReviewContent, + markdownSourceLineOffset, + rootRef, + scrollContainerRef, + worktreeId, + worktreeRoot }) const tableOfContentsItems = useMemo(() => buildMarkdownTableOfContents(content), [content]) const flatTableOfContentsItems = useMemo( @@ -728,49 +152,6 @@ export default function RichMarkdownEditor({ onSaveRef.current = onSave onOpenDocLinkRef.current = onOpenDocLink isEditingLinkRef.current = isEditingLink - annotationPopoverRef.current = annotationPopover - canAnnotateRichMarkdownRef.current = canAnnotateRichMarkdown - slashMenuRef.current = slashMenu - docLinkMenuRef.current = docLinkMenu - markdownCommentsRef.current = markdownComments - notePositionsRef.current = notePositions - markdownSourceLineOffsetRef.current = markdownSourceLineOffset - - // Why: selection belongs to the active menu query so query changes can reset - // and clamp during render without a post-render repair Effect. - const setSelectedCommandIndex = useCallback>>( - (nextIndex) => { - setSlashSelection((current) => { - const query = slashMenuRef.current?.query ?? null - const optionCount = filteredSlashCommandsRef.current.length - const currentIndex = - current.query === query ? clampMenuSelectionIndex(current.index, optionCount) : 0 - const resolvedIndex = typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex - return { - query, - index: clampMenuSelectionIndex(resolvedIndex, optionCount) - } - }) - }, - [] - ) - - const setSelectedDocLinkIndex = useCallback>>( - (nextIndex) => { - setDocLinkSelection((current) => { - const query = docLinkMenuRef.current?.query ?? null - const rowCount = filteredDocLinkRowsRef.current.length - const currentIndex = - current.query === query ? clampMenuSelectionIndex(current.index, rowCount) : 0 - const resolvedIndex = typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex - return { - query, - index: clampMenuSelectionIndex(resolvedIndex, rowCount) - } - }) - }, - [] - ) const flushPendingSerialization = useCallback(() => { if (serializeTimerRef.current === null) { @@ -797,643 +178,67 @@ export default function RichMarkdownEditor({ return registerPendingEditorFlush(fileId, flushPendingSerialization) }, [fileId, flushPendingSerialization]) - const clearAttentionTimers = useCallback(() => { - if (attentionReviewCommentTimeoutRef.current !== null) { - window.clearTimeout(attentionReviewCommentTimeoutRef.current) - attentionReviewCommentTimeoutRef.current = null - } - if (sourceAttentionTimeoutRef.current !== null) { - window.clearTimeout(sourceAttentionTimeoutRef.current) - sourceAttentionTimeoutRef.current = null - } - }, []) - - const clearReviewCopyTimers = useCallback(() => { - if (reviewNotesCopiedResetTimerRef.current !== null) { - window.clearTimeout(reviewNotesCopiedResetTimerRef.current) - reviewNotesCopiedResetTimerRef.current = null - } - if (copiedReviewNoteResetTimerRef.current !== null) { - window.clearTimeout(copiedReviewNoteResetTimerRef.current) - copiedReviewNoteResetTimerRef.current = null - } - }, []) - - const clearAllAnnotationHighlights = useCallback((): void => { - const ed = editorRef.current - if (!ed) { - return - } - ed.view.dispatch( - ed.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { - activeRange: null, - noteRanges: [] - }) - ) - }, []) - + const { clearTransientReviewState } = review const setRootElement = useCallback( (node: HTMLDivElement | null) => { if (node === null) { // Why: these transient editor resources are owned by this root; clearing // them at detach keeps unmount cleanup out of passive Effects. - clearAttentionTimers() - clearReviewCopyTimers() - clearAllAnnotationHighlights() - if (annotationTargetFrameRef.current !== null) { - window.cancelAnimationFrame(annotationTargetFrameRef.current) - annotationTargetFrameRef.current = null - } - if (notePositionsFrameRef.current !== null) { - window.cancelAnimationFrame(notePositionsFrameRef.current) - notePositionsFrameRef.current = null - } + clearTransientReviewState() cancelAutoFocusRef.current?.() cancelAutoFocusRef.current = null window.api.ui.setMarkdownEditorFocused(false) } rootRef.current = node }, - [clearAllAnnotationHighlights, clearAttentionTimers, clearReviewCopyTimers] + [clearTransientReviewState] ) - const syncAnnotationTarget = useCallback((nextEditor: Editor): void => { - if (annotationTargetFrameRef.current !== null) { - window.cancelAnimationFrame(annotationTargetFrameRef.current) - } - annotationTargetFrameRef.current = window.requestAnimationFrame(() => { - annotationTargetFrameRef.current = null - const root = rootRef.current - if (!root || annotationPopoverRef.current || !canAnnotateRichMarkdownRef.current) { - setAnnotationTarget(null) - return - } - const target = getRichMarkdownAnnotationTarget(nextEditor, root) - if ( - target && - hasRichMarkdownCommentForRange( - markdownCommentsRef.current, - target, - markdownSourceLineOffsetRef.current - ) - ) { - setAnnotationTarget(null) - return - } - setAnnotationTarget(target) - }) - }, []) - - const pulseRichMarkdownReviewNote = useCallback((commentId: string): void => { - if (attentionReviewCommentTimeoutRef.current !== null) { - window.clearTimeout(attentionReviewCommentTimeoutRef.current) - } - setAttentionReviewCommentId(null) - window.requestAnimationFrame(() => { - setAttentionReviewCommentId(commentId) - attentionReviewCommentTimeoutRef.current = window.setTimeout(() => { - setAttentionReviewCommentId(null) - attentionReviewCommentTimeoutRef.current = null - }, 900) - }) - }, []) - - const markReviewNotesCopied = useCallback((): void => { - clearReviewCopyTimers() - setCopiedReviewNoteId(null) - setReviewNotesCopied(true) - reviewNotesCopiedResetTimerRef.current = window.setTimeout(() => { - reviewNotesCopiedResetTimerRef.current = null - setReviewNotesCopied(false) - }, 1600) - }, [clearReviewCopyTimers]) - - const markReviewNoteCopied = useCallback((noteId: string): void => { - if (copiedReviewNoteResetTimerRef.current !== null) { - window.clearTimeout(copiedReviewNoteResetTimerRef.current) - } - setCopiedReviewNoteId(noteId) - copiedReviewNoteResetTimerRef.current = window.setTimeout(() => { - copiedReviewNoteResetTimerRef.current = null - setCopiedReviewNoteId(null) - }, 1600) - }, []) - - const handleCopyMarkdownReviewNotes = useCallback(async (): Promise => { - try { - const copied = await copyMarkdownReviewNotesForAgent({ - notes: markdownReviewNotes, - content: markdownReviewContent, - writeClipboardText: window.api.ui.writeClipboardText - }) - if (copied && rootRef.current) { - markReviewNotesCopied() - } - } catch { - // Best-effort clipboard action; failures usually mean the window is not focused. - } - }, [markdownReviewContent, markdownReviewNotes, markReviewNotesCopied]) - - const handleCopyMarkdownReviewNote = useCallback( - async (note: MarkdownReviewNote): Promise => { - try { - const copied = await copyMarkdownReviewNotesForAgent({ - notes: [note], - content: markdownReviewContent, - writeClipboardText: window.api.ui.writeClipboardText - }) - if (copied && rootRef.current) { - markReviewNoteCopied(note.id) - } - } catch { - // Best-effort clipboard action; failures usually mean the window is not focused. - } - }, - [markdownReviewContent, markReviewNoteCopied] - ) - - const syncNotePositions = useCallback((): void => { - const ed = editorRef.current - const container = scrollContainerRef.current - if ( - !reviewRailVisible || - !canAnnotateRichMarkdown || - !ed || - !container || - markdownComments.length === 0 - ) { - clearRichMarkdownNotePositions(setNotePositions) - return - } - const containerRect = container.getBoundingClientRect() - const blocks = buildRichMarkdownCommentBlocks(ed) - const nextPositions = markdownComments - .map((comment): RichMarkdownReviewNotePosition | null => { - const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset) - const block = blocks.find( - (candidate) => - candidate.startLine <= bodyLineNumber && bodyLineNumber <= candidate.endLine - ) - if (!block) { - return null - } - const top = getRichMarkdownCommentAnchorTop( - ed, - comment, - block, - containerRect, - container.scrollTop, - markdownSourceLineOffset - ) - if (top === null) { - return null - } - return { comment, top } - }) - .filter((position): position is RichMarkdownReviewNotePosition => position !== null) - - const measuredHeights = new Map() - for (const pos of nextPositions) { - const id = pos.comment.id - const el = container.querySelector(`[data-rich-markdown-review-note-id="${id}"]`) - if (el) { - measuredHeights.set(id, el.getBoundingClientRect().height) - } - } - - setNotePositions(stackRichMarkdownReviewNotePositions(nextPositions, measuredHeights)) - }, [canAnnotateRichMarkdown, markdownComments, markdownSourceLineOffset, reviewRailVisible]) - - const requestSyncNotePositions = useCallback((): void => { - if (!reviewRailVisible) { - clearRichMarkdownNotePositions(setNotePositions) - return - } - if (notePositionsFrameRef.current !== null) { - return - } - notePositionsFrameRef.current = window.requestAnimationFrame(() => { - notePositionsFrameRef.current = null - syncNotePositions() - }) - }, [reviewRailVisible, syncNotePositions]) - - const scrollRichMarkdownReviewNoteCardIntoView = useCallback( - (commentId: string): void => { - setReviewRailOpen(true) - setActiveReviewCommentId(commentId) - pulseRichMarkdownReviewNote(commentId) - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - const container = scrollContainerRef.current - const card = container?.querySelector( - `[data-rich-markdown-review-note-id="${CSS.escape(commentId)}"]` - ) - if (!container) { - return - } - const position = notePositionsRef.current.find((item) => item.comment.id === commentId) - const cardHeight = card?.offsetHeight ?? 72 - const cardTop = position?.top ?? card?.offsetTop - if (cardTop === undefined) { - return - } - const targetTop = cardTop - Math.max(0, (container.clientHeight - cardHeight) / 2) - container.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }) - }) - }) - }, - [pulseRichMarkdownReviewNote] - ) - - const pulseRichMarkdownSourceRange = useCallback( - (range: RichMarkdownAnnotationHighlightRange): void => { - const ed = editorRef.current - if (!ed) { - return - } - if (sourceAttentionTimeoutRef.current !== null) { - window.clearTimeout(sourceAttentionTimeoutRef.current) - } - ed.view.dispatch( - ed.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { - activeRange: null - }) - ) - window.requestAnimationFrame(() => { - const currentEditor = editorRef.current - if (!currentEditor) { - return - } - currentEditor.view.dispatch( - currentEditor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { - activeRange: range - }) - ) - sourceAttentionTimeoutRef.current = window.setTimeout(() => { - const latestEditor = editorRef.current - if (latestEditor) { - latestEditor.view.dispatch( - latestEditor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { - activeRange: null - }) - ) - } - sourceAttentionTimeoutRef.current = null - }, 900) - }) - }, - [] - ) - - const scrollRichMarkdownReviewNoteSourceIntoView = useCallback( - (comment: DiffComment): void => { - const ed = editorRef.current - const container = scrollContainerRef.current - if (!ed || !container) { - return - } - const ranges = getRichMarkdownAnnotationHighlightRangesForComment( - ed, - comment, - markdownSourceLineOffsetRef.current - ) - if (ranges.length === 0) { - return - } - const bounds = getRichMarkdownRangeBounds(ranges) - if (!bounds) { - return - } - const { from, to } = bounds - const maxPos = ed.state.doc.content.size - const startCoords = ed.view.coordsAtPos(Math.max(1, Math.min(from, maxPos))) - const endCoords = ed.view.coordsAtPos(Math.max(1, Math.min(to, maxPos))) - const containerRect = container.getBoundingClientRect() - const sourceTop = startCoords.top - containerRect.top + container.scrollTop - const sourceBottom = endCoords.bottom - containerRect.top + container.scrollTop - const targetTop = (sourceTop + sourceBottom) / 2 - container.clientHeight / 2 - setActiveReviewCommentId(comment.id) - container.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }) - pulseRichMarkdownSourceRange({ from, to }) - }, - [pulseRichMarkdownSourceRange] - ) - - const editor = useEditor({ - immediatelyRender: false, - extensions: richMarkdownExtensions, - content: encodeRawMarkdownHtmlForRichEditor(content), - contentType: 'markdown', - editorProps: { - attributes: { - class: 'rich-markdown-editor', - spellcheck: 'true' - }, - handleDOMEvents: { - cut: handleRichMarkdownCut - }, - handleTextInput: (view, from, to, text) => { - typedEmptyOrderedListMarkerRef.current = false - if (text !== ' ' || from !== to || !view.state.selection.empty) { - return false - } - const { $from } = view.state.selection - const beforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0') - // Why: only a typed ordered-list shortcut should preserve `1.` on - // Enter; toolbar/slash/context-created empty lists should exit normally. - typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor) - return false - }, - handleKeyDown: createRichMarkdownKeyHandler({ - isMac, - editorRef, - rootRef, - lastCommittedMarkdownRef, - onContentChangeRef, - onSaveRef, - isEditingLinkRef, - slashMenuRef, - filteredSlashCommandsRef, - selectedCommandIndexRef, - docLinkMenuRef, - filteredDocLinkRowsRef, - selectedDocLinkIndexRef, - handleLocalImagePickRef, - handleEmojiPickRef, - typedEmptyOrderedListMarkerRef, - flushPendingSerialization, - openSearchRef, - setIsEditingLink, - setLinkBubble, - setSelectedCommandIndex, - setSelectedDocLinkIndex, - setSlashMenu, - setDocLinkMenu - }), - // Why: Cmd/Ctrl-click activates links via the shared classifier + - // dispatcher, so in-worktree .md links open in an Orca tab instead of the - // OS default handler. Cmd/Ctrl+Shift-click is the OS escape hatch, kept - // symmetric with MarkdownPreview. Without a modifier the click falls - // through to TipTap's default cursor-positioning behavior. - // Why: ProseMirror fires handleClick before updating the selection, so - // ed.isActive('link') reads the *old* cursor position. We resolve the - // link mark directly at the clicked pos instead. - handleClick: (view, pos, event) => { - const ed = editorRef.current - const modKey = isMac ? event.metaKey : event.ctrlKey - if (!ed) { - return false - } - if (!modKey) { - const selectedComment = getRichMarkdownCommentAtPos( - ed, - markdownCommentsRef.current, - markdownSourceLineOffsetRef.current, - pos - ) - if (!selectedComment) { - return false - } - scrollRichMarkdownReviewNoteCardIntoView(selectedComment.id) - return false - } - // Why: doc links are atom nodes (not marks), so resolve(pos).marks() - // won't find them. Check nodeAt(pos) first for doc link navigation. - const clickedNode = view.state.doc.nodeAt(pos) - if (clickedNode?.type.name === 'image') { - const src = (clickedNode.attrs.src as string | undefined) ?? '' - if (!src) { - return false - } - void activateMarkdownLink(src, { - sourceFilePath: filePath, - worktreeId, - worktreeRoot, - runtimeEnvironmentId - }) - return true - } - if (clickedNode?.type.name === 'markdownDocLink') { - const target = clickedNode.attrs.target as string - if (target && onOpenDocLinkRef.current) { - onOpenDocLinkRef.current(target) - } - return true - } - const linkMark = view.state.doc - .resolve(pos) - .marks() - .find((m) => m.type.name === 'link') - const href = linkMark ? (linkMark.attrs.href as string) || '' : '' - if (!href) { - return false - } - if (href.startsWith('#')) { - scrollToAnchorInEditor(rootRef.current, href.slice(1)) - return true - } - if (event.shiftKey) { - const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot) - if (!classified) { - return true - } - if (classified.kind === 'external') { - openHttpLink(classified.url, { forceSystemBrowser: true }) - return true - } - if ( - isLocalPathOpenBlocked( - settingsForRuntimeOwner(useAppStore.getState().settings, runtimeEnvironmentId), - { connectionId: getConnectionId(worktreeId) } - ) - ) { - // Why: Shift-click opens through the client OS. Server-local paths - // from remote runtime/SSH worktrees are not meaningful on this client. - showLocalPathOpenBlockedToast() - return true - } - if (classified.kind === 'markdown') { - void window.api.shell.pathExists(classified.absolutePath).then((exists) => { - if (!exists) { - toast.error(`File not found: ${classified.relativePath}`) - return - } - void window.api.shell.openFileUri(toFileUrlForOsEscape(classified.absolutePath)) - }) - } else if (classified.kind === 'file') { - void window.api.shell.openFileUri(classified.uri) - } - return true - } - void activateMarkdownLink(href, { - sourceFilePath: filePath, - worktreeId, - worktreeRoot, - runtimeEnvironmentId - }) - return true - } - }, - onFocus: () => { - // Why: mirror TipTap focus into the main process so the before-input-event - // Cmd+B carve-out in createMainWindow.ts lets the bold keymap run instead - // of intercepting the chord for sidebar toggle. - // See docs/markdown-cmd-b-bold-design.md. - window.api.ui.setMarkdownEditorFocused(true) - }, - onBlur: () => { - window.api.ui.setMarkdownEditorFocused(false) - setAnnotationTarget(null) - }, - onCreate: ({ editor: nextEditor }) => { - // Why: markdown soft line breaks produce paragraphs with embedded `\n` chars. - // Normalizing them into separate paragraph nodes on load ensures Cmd+X (and - // other block-level operations) treat each line as its own block. - normalizeSoftBreaks(nextEditor) - // Why: raw disk content is the source of truth for dirty/external-change - // detection. getMarkdown() may round-trip soft breaks or trailing newlines - // differently, which would otherwise force a spurious mount-time re-sync. - lastCommittedMarkdownRef.current = content - // Why: clear the flag *after* normalizeSoftBreaks so any onUpdate - // triggered by the normalization transaction is still suppressed. - isInitializingRef.current = false - // Why: MonacoEditor already auto-focuses on mount so users can start - // typing immediately. The rich markdown editor must do the same, - // otherwise opening a new markdown file (Cmd+Shift+N) or switching to - // an existing markdown tab leaves the cursor outside the editing - // surface and the user has to click before typing. - cancelAutoFocusRef.current?.() - cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current) - }, - onUpdate: ({ editor: nextEditor }) => { - syncSlashMenu(nextEditor, rootRef.current, setSlashMenu) - syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu) - if (!isSingleEmptyTopLevelOrderedList(nextEditor)) { - typedEmptyOrderedListMarkerRef.current = false - } - - // Why: bail out during normalizeSoftBreaks's onCreate transaction so the - // structural housekeeping doesn't mark the file dirty before the user - // has typed anything. - if (isInitializingRef.current || isApplyingProgrammaticUpdateRef.current) { - return - } - - // Why: optimistically mark dirty for close-confirmation before the - // debounced content sync computes the exact saved-vs-draft comparison. - onDirtyStateHintRef.current(true) - - // Why: getMarkdown() is the typing-speed bottleneck for large files; - // debouncing to 300ms keeps drafts current without blocking input. - if (serializeTimerRef.current !== null) { - window.clearTimeout(serializeTimerRef.current) - } - serializeTimerRef.current = window.setTimeout(() => { - serializeTimerRef.current = null - try { - const markdown = nextEditor.getMarkdown() - lastCommittedMarkdownRef.current = markdown - onContentChangeRef.current(markdown) - } catch { - // Why: save/restart flows should never crash the UI just because the - // editor was torn down between scheduling and flushing a debounced sync. - } - }, 300) - }, - onSelectionUpdate: ({ editor: nextEditor }) => { - syncSlashMenu(nextEditor, rootRef.current, setSlashMenu) - syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu) - syncAnnotationTarget(nextEditor) - - // Sync link bubble: show preview when cursor is on a link, hide otherwise. - // Any selection change in the editor cancels an in-progress link edit. - setIsEditingLink(false) - if (nextEditor.isActive('link')) { - const attrs = nextEditor.getAttributes('link') - const pos = getLinkBubblePosition(nextEditor, rootRef.current) - if (pos) { - setLinkBubble({ href: (attrs.href as string) || '', ...pos }) - } - } else { - setLinkBubble(null) - } - } + const editor = useRichMarkdownEditorInstance({ + content, + filePath, + worktreeId, + worktreeRoot, + runtimeEnvironmentId, + isMac, + settings, + activateMarkdownLink, + rootRef, + editorRef, + lastCommittedMarkdownRef, + onContentChangeRef, + onDirtyStateHintRef, + onSaveRef, + onOpenDocLinkRef, + isEditingLinkRef, + slashMenuRef: menu.slashMenuRef, + filteredSlashCommandsRef: menu.filteredSlashCommandsRef, + selectedCommandIndexRef: menu.selectedCommandIndexRef, + docLinkMenuRef: menu.docLinkMenuRef, + filteredDocLinkRowsRef: menu.filteredDocLinkRowsRef, + selectedDocLinkIndexRef: menu.selectedDocLinkIndexRef, + handleLocalImagePickRef, + handleEmojiPickRef: menu.handleEmojiPickRef, + typedEmptyOrderedListMarkerRef, + cancelAutoFocusRef, + serializeTimerRef, + isInitializingRef, + isApplyingProgrammaticUpdateRef, + markdownCommentsRef: review.markdownCommentsRef, + markdownSourceLineOffsetRef: review.markdownSourceLineOffsetRef, + flushPendingSerialization, + openSearchRef, + syncAnnotationTarget: review.syncAnnotationTarget, + clearAnnotationTarget: review.clearAnnotationTarget, + scrollRichMarkdownReviewNoteCardIntoView: review.scrollRichMarkdownReviewNoteCardIntoView, + setIsEditingLink, + setLinkBubble, + setSelectedCommandIndex: menu.setSelectedCommandIndex, + setSelectedDocLinkIndex: menu.setSelectedDocLinkIndex, + setSlashMenu: menu.setSlashMenu, + setDocLinkMenu: menu.setDocLinkMenu }) - editorRef.current = editor ?? null - - const clearAnnotationHighlight = useCallback((): void => { - const ed = editorRef.current - if (!ed) { - return - } - ed.view.dispatch(ed.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, null)) - }, []) - - useEffect(() => { - if (canAnnotateRichMarkdown) { - return - } - setAnnotationTarget(null) - setAnnotationPopover(null) - clearAllAnnotationHighlights() - }, [canAnnotateRichMarkdown, clearAllAnnotationHighlights]) - - useEffect(() => { - if (!editor || !canAnnotateRichMarkdown) { - return - } - const noteRanges = getRichMarkdownAnnotationHighlightRanges( - editor, - markdownComments, - markdownSourceLineOffset - ) - editor.view.dispatch( - editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { noteRanges }) - ) - }, [canAnnotateRichMarkdown, content, editor, markdownComments, markdownSourceLineOffset]) - - useEffect(() => { - if (!editor) { - return - } - const container = scrollContainerRef.current - if (!container) { - return - } - const update = (): void => syncAnnotationTarget(editor) - container.addEventListener('scroll', update) - window.addEventListener('resize', update) - return () => { - container.removeEventListener('scroll', update) - window.removeEventListener('resize', update) - } - }, [editor, syncAnnotationTarget]) - - useEffect(() => { - requestSyncNotePositions() - }, [content, editor, markdownComments, requestSyncNotePositions]) - - useEffect(() => { - if (!reviewRailVisible) { - clearRichMarkdownNotePositions(setNotePositions) - return - } - const container = scrollContainerRef.current - if (!container) { - return - } - const update = (): void => requestSyncNotePositions() - container.addEventListener('scroll', update, { passive: true }) - window.addEventListener('resize', update) - requestSyncNotePositions() - return () => { - container.removeEventListener('scroll', update) - window.removeEventListener('resize', update) - } - }, [requestSyncNotePositions, reviewRailVisible]) - // Why: use useLayoutEffect (synchronous cleanup) so the pending serialization // flush runs before useEditor's cleanup destroys the editor instance on tab // switch or mode change. React runs layout-effect cleanups before effect @@ -1446,47 +251,32 @@ export default function RichMarkdownEditor({ useModifierHeldClass(rootRef, isMac) - // Why: the custom Image extension reads filePath/runtimeContext from storage - // to resolve relative image src values. After updating storage we dispatch a - // no-op transaction so ProseMirror re-renders image nodes with the new source. - useEffect(() => { - if (editor) { - isApplyingProgrammaticUpdateRef.current = true - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(editor.storage as any).image.filePath = filePath - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(editor.storage as any).image.runtimeContext = worktreeRoot - ? { - settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), - worktreeId, - worktreePath: worktreeRoot, - connectionId: getConnectionId(worktreeId) - } - : undefined - editor.view.dispatch(editor.state.tr) - } finally { - isApplyingProgrammaticUpdateRef.current = false - } - } - }, [editor, filePath, runtimeEnvironmentId, settings, worktreeId, worktreeRoot]) + useRichMarkdownReviewEditorEffects({ + canAnnotateRichMarkdown: review.canAnnotateRichMarkdown, + content, + editor, + markdownComments: review.markdownComments, + markdownSourceLineOffset, + scrollContainerRef, + syncAnnotationTarget: review.syncAnnotationTarget + }) - // Why: the doc link NodeView reads the document list from storage to style - // resolved vs. missing links. The no-op transaction with meta flag triggers - // both nodeView `update` callbacks and the decoration plugin rebuild. - useEffect(() => { - if (editor && markdownDocuments) { - isApplyingProgrammaticUpdateRef.current = true - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(editor.storage as any).markdownDocLink.documents = markdownDocuments - const tr = editor.state.tr.setMeta('docLinksUpdated', true) - editor.view.dispatch(tr) - } finally { - isApplyingProgrammaticUpdateRef.current = false - } - } - }, [editor, markdownDocuments]) + useRichMarkdownProgrammaticSync({ + content, + docLinkMenuSetter: menu.setDocLinkMenu, + editor, + fileId, + filePath, + isApplyingProgrammaticUpdateRef, + lastCommittedMarkdownRef, + markdownDocuments, + rootRef, + runtimeEnvironmentId, + settings, + slashMenuSetter: menu.setSlashMenu, + worktreeId, + worktreeRoot + }) const handleLocalImagePick = useLocalImagePick(editor, filePath, worktreeId, runtimeEnvironmentId) handleLocalImagePickRef.current = handleLocalImagePick @@ -1511,12 +301,12 @@ export default function RichMarkdownEditor({ return } - runRichMarkdownContextCommand( - payload.command, - ed, - toggleLinkFromToolbar, - handleLocalImagePick - ) + runRichMarkdownContextCommand({ + command: payload.command, + editor: ed, + toggleLink: toggleLinkFromToolbar, + pickImage: handleLocalImagePick + }) }) }, [handleLocalImagePick, toggleLinkFromToolbar]) @@ -1556,485 +346,82 @@ export default function RichMarkdownEditor({ [flatTableOfContentsItems] ) - const openEmojiMenu = useCallback((menu: SlashMenuState): void => { - setSlashMenu(null) - setEmojiMenu({ left: menu.left, top: menu.top }) - }, []) - - const submitAnnotation = useCallback( - async (body: string): Promise => { - if (!annotationPopover || sourceRelativePath === null) { - return - } - const result = await addDiffComment({ - worktreeId, - filePath: sourceRelativePath, - source: 'markdown', - startLine: - annotationPopover.startLine === undefined - ? undefined - : annotationPopover.startLine + markdownSourceLineOffset, - lineNumber: annotationPopover.lineNumber + markdownSourceLineOffset, - selectedText: annotationPopover.selectedText, - body, - side: 'modified' - }) - if (result) { - const ed = editorRef.current - if (ed) { - const noteRanges = getRichMarkdownAnnotationHighlightRanges( - ed, - [...markdownComments, result], - markdownSourceLineOffset - ) - const hasSubmittedRange = noteRanges.some( - (range) => range.from <= annotationPopover.from && annotationPopover.to <= range.to - ) - ed.view.dispatch( - ed.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { - activeRange: null, - noteRanges: hasSubmittedRange - ? noteRanges - : [...noteRanges, { from: annotationPopover.from, to: annotationPopover.to }] - }) - ) - } - setAnnotationPopover(null) - clearAnnotationHighlight() - window.getSelection()?.removeAllRanges() - } else { - console.error('Failed to add markdown comment — draft preserved') - } - }, - [ - addDiffComment, - annotationPopover, - clearAnnotationHighlight, - markdownComments, - markdownSourceLineOffset, - sourceRelativePath, - worktreeId - ] - ) - - const openAnnotationPopover = useCallback((): void => { - if (!annotationTarget || !canAnnotateRichMarkdown) { - return - } - const ed = editorRef.current - const root = rootRef.current - const liveTarget = ed && root ? getRichMarkdownAnnotationTarget(ed, root) : null - const target = ed - ? clampRichMarkdownAnnotationTarget(ed, liveTarget ?? annotationTarget) - : annotationTarget - if (!target) { - setAnnotationTarget(null) - return - } - if (hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset)) { - setAnnotationTarget(null) - return - } - if (ed) { - ed.view.dispatch( - ed.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { - activeRange: { - from: target.from, - to: target.to - } - }) - ) - } - // Why: opening a draft should reserve the notes rail immediately; after - // submit, the saved note stays visible instead of landing behind a closed toggle. - setReviewRailOpen(true) - setAnnotationPopover(target) - setAnnotationTarget(null) - }, [annotationTarget, canAnnotateRichMarkdown, markdownComments, markdownSourceLineOffset]) - - handleEmojiPickRef.current = openEmojiMenu - - const filteredSlashCommands = useMemo(() => { - const query = slashMenu?.query.trim().toLowerCase() ?? '' - if (!query) { - return slashCommands - } - return slashCommands.filter((command) => { - const haystack = [command.label, ...command.aliases].join(' ').toLowerCase() - return haystack.includes(query) - }) - }, [slashMenu?.query]) - - const slashMenuQuery = slashMenu?.query ?? null - const selectedCommandIndex = - slashSelection.query === slashMenuQuery - ? clampMenuSelectionIndex(slashSelection.index, filteredSlashCommands.length) - : 0 - filteredSlashCommandsRef.current = filteredSlashCommands - selectedCommandIndexRef.current = selectedCommandIndex - - // Why: memo key is the `markdownDocuments` prop (stable reference from parent), - // not `editor.storage.markdownDocLink.documents`. The storage mirror is mutated - // in place by the extension so React would not see a new reference and the memo - // would stale-out. The prop is the single source of truth for filtering. - const DOC_LINK_MENU_MAX_ROWS = 20 - const { docLinkRows, docLinkTotalMatches } = useMemo(() => { - if (!docLinkMenu || !markdownDocuments) { - return { docLinkRows: [] as DocLinkMenuRow[], docLinkTotalMatches: 0 } - } - const matches = getMarkdownDocCompletionDocuments(markdownDocuments, docLinkMenu.query) - const rows: DocLinkMenuRow[] = matches - .slice(0, DOC_LINK_MENU_MAX_ROWS) - .map((document) => ({ kind: 'document', document })) - return { docLinkRows: rows, docLinkTotalMatches: matches.length } - }, [docLinkMenu, markdownDocuments]) - - const docLinkMenuQuery = docLinkMenu?.query ?? null - const selectedDocLinkIndex = - docLinkSelection.query === docLinkMenuQuery - ? clampMenuSelectionIndex(docLinkSelection.index, docLinkRows.length) - : 0 - filteredDocLinkRowsRef.current = docLinkRows - selectedDocLinkIndexRef.current = selectedDocLinkIndex - - useEffect(() => { - if (!editor) { - return - } - - // Why: the debounced onUpdate serializes the editor and feeds it back - // through onContentChange → editorDrafts → the content prop. If the - // user typed between the debounce firing and this effect running, the - // editor already contains newer content than the prop. Comparing - // against lastCommittedMarkdownRef (which is set in the same tick as - // onContentChange) lets us recognise our own serialization and skip the - // destructive setContent that would reset the cursor mid-typing. - if (content === lastCommittedMarkdownRef.current) { - return - } - - const currentMarkdown = editor.getMarkdown() - if (currentMarkdown === content) { - return - } - - // Why: markdown files on disk remain the source of truth for rich mode in - // Orca. External file changes, tab replacement, and save-after-reload must - // overwrite the editor state so the rich view never drifts from repo text. - isApplyingProgrammaticUpdateRef.current = true - try { - // Why: swallow exceptions from setContent / normalizeSoftBreaks here - // rather than letting them escape to the React root. Under split-pane - // external reload (two RichMarkdownEditor instances receiving the same - // Claude Code write), a throw from the TipTap/ProseMirror transaction - // would otherwise unmount the entire renderer and black the whole - // window out (issue #826). The committed-markdown ref is deliberately - // left pointing at the pre-failure value so the next prop change still - // triggers a re-sync attempt instead of being short-circuited by the - // `content === lastCommittedMarkdownRef.current` guard above. - try { - // Why: TipTap's setContent collapses the selection to the end of the - // new document by default. When the editor is focused (user is - // actively typing), that reads as a spontaneous cursor jump to EOF. - // Snapshot the current selection bounds and restore them clamped to - // the new doc length after the content swap so the caret stays put - // for any genuinely external edit that lands during a typing session. - // The old doc's offsets are a best-effort heuristic — for a real - // external rewrite they won't map to the semantically equivalent - // position, but this is still strictly better than jumping to EOF. - const hadFocus = editor.isFocused - const { from: prevFrom, to: prevTo } = editor.state.selection - editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(content), { - contentType: 'markdown', - emitUpdate: false - }) - // Why: same soft-break normalization as onCreate — external content updates - // may re-introduce paragraphs with embedded `\n` characters. - normalizeSoftBreaks(editor) - lastCommittedMarkdownRef.current = content - if (hadFocus) { - // Why: setContent can blur the editor via ProseMirror's focus - // handling, so restoring selection alone would leave subsequent - // keystrokes going to the browser. Chain focus() after the - // selection restore to keep the typing session intact. - const docSize = editor.state.doc.content.size - editor - .chain() - .setTextSelection({ from: Math.min(prevFrom, docSize), to: Math.min(prevTo, docSize) }) - .focus() - .run() - } - } catch (err) { - console.error('[RichMarkdownEditor] failed to apply external content update', err) - } - } finally { - isApplyingProgrammaticUpdateRef.current = false - } - syncSlashMenu(editor, rootRef.current, setSlashMenu) - syncDocLinkMenu(editor, rootRef.current, setDocLinkMenu) - // Why: fileId is part of the dep array so switching between files (where - // content can coincidentally match what was last committed for the prior - // file) still triggers the content-sync path and prevents cross-file - // drift from the renderer's draft cache. - }, [content, editor, fileId]) - return ( -
-
- - {headerSlot} - {/* Why: wrap scroll area + search bar in a relative container so the - search bar overlays the content (Monaco-style) instead of occupying - layout space and shifting the document down when opened. */} -
-
{ - if (!shouldFocusEmptyEditorFromSurfaceClick(event, editorRef.current)) { - return - } - // Why: native contenteditable only places the caret on actual line - // boxes; an empty note should still focus when the user clicks any - // blank part of the document surface. - event.preventDefault() - editorRef.current?.commands.focus('start') - }} - > - - {reviewRailVisible && notePositions.length > 0 ? ( -
- {notePositions.map(({ comment, top }) => ( -
event.stopPropagation()} - onClick={(event) => { - if (!isRichMarkdownReviewNoteNavigationClick(event.target)) { - return - } - scrollRichMarkdownReviewNoteSourceIntoView(comment) - }} - > - void deleteDiffComment(worktreeId, comment.id)} - onSubmitEdit={(body) => updateDiffComment(worktreeId, comment.id, body)} - onContentResize={syncNotePositions} - headerActions={ - <> - - - void clearDeliveredDiffComments(worktreeId, notes) - } - /> - - } - /> -
- ))} -
- ) : null} -
- -
- {linkBubble ? ( - setIsEditingLink(true)} - onEditCancel={handleLinkEditCancel} - onOpen={handleLinkOpen} - /> - ) : null} - {slashMenu ? ( - openEmojiMenu(slashMenu)} - /> - ) : null} - {emojiMenu ? ( - setEmojiMenu(null)} - /> - ) : null} - {docLinkMenu ? ( - - ) : null} - {annotationTarget ? ( - - ) : null} - {annotationPopover ? ( - { - setAnnotationPopover(null) - clearAnnotationHighlight() - }} - onSubmit={submitAnnotation} - /> - ) : null} - {hasMarkdownComments ? ( -
- - - void clearDeliveredDiffComments(worktreeId, notes)} - /> -
- ) : null} -
- {showTableOfContents ? ( - {})} - onNavigate={navigateToTableOfContentsItem} - /> - ) : null} -
+ menu.setEmojiMenu(null)} + onOpenAnnotationPopover={review.openAnnotationPopover} + onCancelAnnotationPopover={() => { + review.setAnnotationPopover(null) + review.clearAnnotationHighlight() + }} + onSubmitAnnotation={review.submitAnnotation} + onCopyReviewNotes={() => void review.handleCopyMarkdownReviewNotes()} + onCopyReviewNote={(note) => void review.handleCopyMarkdownReviewNote(note)} + onToggleReviewRail={() => review.setReviewRailOpen((open) => !open)} + onReviewNotesDelivered={(notes) => void clearDeliveredDiffComments(worktreeId, notes)} + onReviewNoteSourceClick={review.scrollRichMarkdownReviewNoteSourceIntoView} + onDeleteReviewComment={(commentId) => void deleteDiffComment(worktreeId, commentId)} + onSubmitReviewCommentEdit={(commentId, body) => + updateDiffComment(worktreeId, commentId, body) + } + onReviewNoteContentResize={review.syncNotePositions} + onNavigateTableOfContentsItem={navigateToTableOfContentsItem} + onCloseTableOfContents={onCloseTableOfContents} + /> ) } diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx new file mode 100644 index 00000000000..41a61ba6ad7 --- /dev/null +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -0,0 +1,292 @@ +import type React from 'react' +import { EditorContent } from '@tiptap/react' +import type { Editor } from '@tiptap/react' +import { RichMarkdownToolbar } from './RichMarkdownToolbar' +import { RichMarkdownSearchBar } from './RichMarkdownSearchBar' +import { RichMarkdownSlashMenu } from './RichMarkdownSlashMenu' +import { RichMarkdownDocLinkMenu } from './RichMarkdownDocLinkMenu' +import { RichMarkdownEmojiMenu } from './RichMarkdownEmojiMenu' +import { RichMarkdownLinkBubble, type LinkBubbleState } from './RichMarkdownLinkBubble' +import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel' +import { RichMarkdownAnnotationOverlay } from './RichMarkdownAnnotationOverlay' +import { RichMarkdownReviewNoteLayer } from './RichMarkdownReviewNoteLayer' +import { RichMarkdownReviewRailActions } from './RichMarkdownReviewRailActions' +import type { DocLinkMenuRow, DocLinkMenuState } from './rich-markdown-commands' +import type { SlashCommand, SlashMenuState } from './rich-markdown-slash-commands' +import type { MarkdownTocItem } from './markdown-table-of-contents' +import type { NotesSendMenuScope } from './NotesSendMenu' +import type { MarkdownReviewNote } from '@/lib/markdown-review-notes' +import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations' +import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' +import type { DiffComment } from '../../../../shared/types' + +function shouldFocusEmptyEditorFromSurfaceClick( + event: React.MouseEvent, + editor: Editor | null +): boolean { + if (!editor?.isEmpty || event.button !== 0) { + return false + } + const target = event.target + if (!(target instanceof Element)) { + return false + } + return !target.closest('.rich-markdown-editor-shell button, .rich-markdown-editor-shell input') +} + +type RichMarkdownEditorSurfaceProps = { + editor: Editor | null + editorFontZoomLevel: number + rootRef: (node: HTMLDivElement | null) => void + scrollContainerRef: React.RefObject + headerSlot?: React.ReactNode + reviewRailExpanded: boolean + reviewRailVisible: boolean + notePositions: RichMarkdownReviewNotePosition[] + activeReviewCommentId: string | null + attentionReviewCommentId: string | null + copiedReviewNoteId: string | null + markdownReviewContent: string + worktreeId: string + filePath: string + markdownCommentsCount: number + reviewRailOpen: boolean + reviewNotesCopied: boolean + unsentMarkdownReviewScope: NotesSendMenuScope[] + linkBubble: LinkBubbleState | null + isEditingLink: boolean + slashMenu: SlashMenuState | null + filteredSlashCommands: SlashCommand[] + selectedCommandIndex: number + emojiMenu: { left: number; top: number } | null + docLinkMenu: DocLinkMenuState | null + docLinkRows: DocLinkMenuRow[] + docLinkTotalMatches: number + selectedDocLinkIndex: number + annotationTarget: RichMarkdownAnnotationTarget | null + annotationPopover: RichMarkdownAnnotationTarget | null + markdownSourceLineOffset: number + tableOfContentsItems: MarkdownTocItem[] + showTableOfContents: boolean + searchState: { + activeMatchIndex: number + isSearchOpen: boolean + matchCount: number + searchQuery: string + searchInputRef: React.RefObject + } + searchActions: { + closeSearch: () => void + moveToMatch: (direction: 1 | -1) => void + setSearchQuery: (query: string) => void + } + linkBubbleActions: { + handleLinkSave: (href: string) => void + handleLinkRemove: () => void + handleLinkEditCancel: () => void + handleLinkOpen: () => void + setIsEditingLink: (editing: boolean) => void + } + onToggleLink: () => void + onImagePick: () => void + onEmojiPick: (menu: SlashMenuState) => void + onCloseEmojiMenu: () => void + onOpenAnnotationPopover: () => void + onCancelAnnotationPopover: () => void + onSubmitAnnotation: (body: string) => Promise + onCopyReviewNotes: () => void + onCopyReviewNote: (note: MarkdownReviewNote) => void + onToggleReviewRail: () => void + onReviewNotesDelivered: (notes: readonly MarkdownReviewNote[]) => void + onReviewNoteSourceClick: (comment: DiffComment) => void + onDeleteReviewComment: (commentId: string) => void + onSubmitReviewCommentEdit: (commentId: string, body: string) => Promise + onReviewNoteContentResize: () => void + onNavigateTableOfContentsItem: (id: string) => void + onCloseTableOfContents?: () => void +} + +export function RichMarkdownEditorSurface({ + editor, + editorFontZoomLevel, + rootRef, + scrollContainerRef, + headerSlot, + reviewRailExpanded, + reviewRailVisible, + notePositions, + activeReviewCommentId, + attentionReviewCommentId, + copiedReviewNoteId, + markdownReviewContent, + worktreeId, + filePath, + markdownCommentsCount, + reviewRailOpen, + reviewNotesCopied, + unsentMarkdownReviewScope, + linkBubble, + isEditingLink, + slashMenu, + filteredSlashCommands, + selectedCommandIndex, + emojiMenu, + docLinkMenu, + docLinkRows, + docLinkTotalMatches, + selectedDocLinkIndex, + annotationTarget, + annotationPopover, + markdownSourceLineOffset, + tableOfContentsItems, + showTableOfContents, + searchState, + searchActions, + linkBubbleActions, + onToggleLink, + onImagePick, + onEmojiPick, + onCloseEmojiMenu, + onOpenAnnotationPopover, + onCancelAnnotationPopover, + onSubmitAnnotation, + onCopyReviewNotes, + onCopyReviewNote, + onToggleReviewRail, + onReviewNotesDelivered, + onReviewNoteSourceClick, + onDeleteReviewComment, + onSubmitReviewCommentEdit, + onReviewNoteContentResize, + onNavigateTableOfContentsItem, + onCloseTableOfContents +}: RichMarkdownEditorSurfaceProps): React.JSX.Element { + return ( +
+
+ + {headerSlot} +
+
{ + if (!shouldFocusEmptyEditorFromSurfaceClick(event, editor)) { + return + } + // Why: native contenteditable only places the caret on actual line + // boxes; an empty note should still focus from blank document space. + event.preventDefault() + editor?.commands.focus('start') + }} + > + + {reviewRailVisible && notePositions.length > 0 ? ( + + ) : null} +
+ +
+ {linkBubble ? ( + linkBubbleActions.setIsEditingLink(true)} + onEditCancel={linkBubbleActions.handleLinkEditCancel} + onOpen={linkBubbleActions.handleLinkOpen} + /> + ) : null} + {slashMenu ? ( + onEmojiPick(slashMenu)} + /> + ) : null} + {emojiMenu ? ( + + ) : null} + {docLinkMenu ? ( + + ) : null} + + {markdownCommentsCount > 0 ? ( + + ) : null} +
+ {showTableOfContents ? ( + {})} + onNavigate={onNavigateTableOfContentsItem} + /> + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx b/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx new file mode 100644 index 00000000000..6127956dd13 --- /dev/null +++ b/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx @@ -0,0 +1,127 @@ +import { Check, Copy } from 'lucide-react' +import type { DiffComment } from '../../../../shared/types' +import { DiffCommentCard } from '../diff-comments/DiffCommentCard' +import { NotesSendMenu } from './NotesSendMenu' +import { + formatMarkdownReviewNotes, + getMarkdownReviewCardQuote, + type MarkdownReviewNote +} from '@/lib/markdown-review-notes' +import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' + +function isRichMarkdownReviewNoteNavigationClick(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + return !target.closest('button,input,textarea,select,a,[contenteditable="true"]') +} + +type RichMarkdownReviewNoteLayerProps = { + positions: RichMarkdownReviewNotePosition[] + activeCommentId: string | null + attentionCommentId: string | null + copiedCommentId: string | null + markdownReviewContent: string + worktreeId: string + filePath: string + onCopyNote: (note: MarkdownReviewNote) => void + onScrollSourceIntoView: (comment: DiffComment) => void + onDeleteComment: (commentId: string) => void + onSubmitEdit: (commentId: string, body: string) => Promise + onContentResize: () => void + onDelivered: (notes: readonly MarkdownReviewNote[]) => void +} + +export function RichMarkdownReviewNoteLayer({ + positions, + activeCommentId, + attentionCommentId, + copiedCommentId, + markdownReviewContent, + worktreeId, + filePath, + onCopyNote, + onScrollSourceIntoView, + onDeleteComment, + onSubmitEdit, + onContentResize, + onDelivered +}: RichMarkdownReviewNoteLayerProps): React.JSX.Element { + return ( +
+ {positions.map(({ comment, top }) => ( +
event.stopPropagation()} + onClick={(event) => { + if (!isRichMarkdownReviewNoteNavigationClick(event.target)) { + return + } + onScrollSourceIntoView(comment) + }} + > + onDeleteComment(comment.id)} + onSubmitEdit={(body) => onSubmitEdit(comment.id, body)} + onContentResize={onContentResize} + headerActions={ + <> + + + + } + /> +
+ ))} +
+ ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownReviewRailActions.tsx b/src/renderer/src/components/editor/RichMarkdownReviewRailActions.tsx new file mode 100644 index 00000000000..ce510489d42 --- /dev/null +++ b/src/renderer/src/components/editor/RichMarkdownReviewRailActions.tsx @@ -0,0 +1,60 @@ +import { Check, Copy, MessageSquare } from 'lucide-react' +import { NotesSendMenu, type NotesSendMenuScope } from './NotesSendMenu' +import type { MarkdownReviewNote } from '@/lib/markdown-review-notes' + +type RichMarkdownReviewRailActionsProps = { + worktreeId: string + filePath: string + noteCount: number + railOpen: boolean + notesCopied: boolean + unsentScope: NotesSendMenuScope[] + onToggleRail: () => void + onCopyNotes: () => void + onDelivered: (notes: readonly MarkdownReviewNote[]) => void +} + +export function RichMarkdownReviewRailActions({ + worktreeId, + filePath, + noteCount, + railOpen, + notesCopied, + unsentScope, + onToggleRail, + onCopyNotes, + onDelivered +}: RichMarkdownReviewRailActionsProps): React.JSX.Element { + return ( +
+ + + +
+ ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownSlashMenu.tsx b/src/renderer/src/components/editor/RichMarkdownSlashMenu.tsx index 798d6ce411c..3743bc9104a 100644 --- a/src/renderer/src/components/editor/RichMarkdownSlashMenu.tsx +++ b/src/renderer/src/components/editor/RichMarkdownSlashMenu.tsx @@ -2,8 +2,8 @@ import React from 'react' import type { Editor } from '@tiptap/react' import { Search } from 'lucide-react' import { cn } from '@/lib/utils' -import { runSlashCommand } from './rich-markdown-commands' -import type { SlashCommand, SlashMenuState } from './rich-markdown-commands' +import { runSlashCommand } from './rich-markdown-slash-commands' +import type { SlashCommand, SlashMenuState } from './rich-markdown-slash-commands' type RichMarkdownSlashMenuProps = { editor: Editor | null diff --git a/src/renderer/src/components/editor/combined-diff-file-tree-row.tsx b/src/renderer/src/components/editor/combined-diff-file-tree-row.tsx new file mode 100644 index 00000000000..7f47909cb10 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff-file-tree-row.tsx @@ -0,0 +1,132 @@ +import type React from 'react' +import { ChevronDown, Folder, FolderOpen } from 'lucide-react' +import { STATUS_COLORS, STATUS_LABELS } from '@/components/right-sidebar/status-display' +import type { SourceControlTreeNode } from '@/components/right-sidebar/source-control-tree' +import { getFileTypeIcon } from '@/lib/file-type-icons' +import { basename, dirname, joinPath } from '@/lib/path' +import { cn } from '@/lib/utils' +import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' +import type { + GitBranchChangeEntry, + GitFileStatus, + GitStagingArea, + GitStatusEntry +} from '../../../../shared/types' +import { + getCombinedDiffFileTreeSectionKey, + type CombinedDiffBranchTreeArea, + type CombinedDiffFileTreeEntry, + type CombinedDiffFileTreeMode +} from './combined-diff-file-tree-model' + +export type CombinedDiffTreeNode = SourceControlTreeNode< + GitStatusEntry | GitBranchChangeEntry, + GitStagingArea | CombinedDiffBranchTreeArea +> + +const COMBINED_DIFF_TREE_INDENT_PX = 12 +const COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX = 8 +const COMBINED_DIFF_TREE_FILE_PADDING_PX = 20 + +export function CombinedDiffFileTreeRow({ + node, + mode, + worktreePath, + activeSectionKey, + sectionIndexByKey, + isCollapsed, + onToggleDirectory, + onNavigate +}: { + node: CombinedDiffTreeNode + mode: CombinedDiffFileTreeMode + worktreePath: string + activeSectionKey: string | null + sectionIndexByKey: ReadonlyMap + isCollapsed: boolean + onToggleDirectory: (key: string) => void + onNavigate: (entry: CombinedDiffFileTreeEntry) => void +}): React.JSX.Element { + if (node.type === 'directory') { + return ( +
{ + event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path)) + event.dataTransfer.effectAllowed = 'copy' + }} + > + + + {node.fileCount} + +
+ ) + } + + const sectionKey = getCombinedDiffFileTreeSectionKey(mode, node.entry) + const FileIcon = getFileTypeIcon(node.entry.path) + const fileName = basename(node.entry.path) + const parentDir = dirname(node.entry.path) + const dirPath = parentDir === '.' ? '' : parentDir + const status = node.entry.status as GitFileStatus + const disabled = !sectionIndexByKey.has(sectionKey) + + return ( + + ) +} diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index b98498fc58f..e26b20e8013 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest' import { Editor } from '@tiptap/core' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import { createRichMarkdownExtensions } from './rich-markdown-extensions' -import type { SlashCommandId } from './rich-markdown-commands' -import { slashCommands } from './rich-markdown-commands' +import type { SlashCommandId } from './rich-markdown-slash-commands' +import { slashCommands } from './rich-markdown-slash-commands' function roundTripMarkdown(content: string): string { const editor = new Editor({ diff --git a/src/renderer/src/components/editor/rich-markdown-annotation-submit-highlights.ts b/src/renderer/src/components/editor/rich-markdown-annotation-submit-highlights.ts new file mode 100644 index 00000000000..fba5d07bdc4 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-annotation-submit-highlights.ts @@ -0,0 +1,39 @@ +import type { Editor } from '@tiptap/react' +import type { DiffComment } from '../../../../shared/types' +import { richMarkdownAnnotationHighlightPluginKey } from './rich-markdown-annotation-highlight' +import { + getRichMarkdownAnnotationHighlightRanges, + type RichMarkdownAnnotationTarget +} from './rich-markdown-review-annotations' + +export function updateRichMarkdownAnnotationHighlightsAfterSubmit({ + annotationPopover, + comments, + editor, + markdownSourceLineOffset +}: { + annotationPopover: RichMarkdownAnnotationTarget + comments: DiffComment[] + editor: Editor | null + markdownSourceLineOffset: number +}): void { + if (!editor) { + return + } + const noteRanges = getRichMarkdownAnnotationHighlightRanges( + editor, + comments, + markdownSourceLineOffset + ) + const hasSubmittedRange = noteRanges.some( + (range) => range.from <= annotationPopover.from && annotationPopover.to <= range.to + ) + editor.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: null, + noteRanges: hasSubmittedRange + ? noteRanges + : [...noteRanges, { from: annotationPopover.from, to: annotationPopover.to }] + }) + ) +} diff --git a/src/renderer/src/components/editor/rich-markdown-commands.test.ts b/src/renderer/src/components/editor/rich-markdown-commands.test.ts index 58f336c9618..fa14c392524 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-commands.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Editor } from '@tiptap/core' import { createRichMarkdownExtensions } from './rich-markdown-extensions' -import { runSlashCommand, slashCommands, type SlashCommandId } from './rich-markdown-commands' +import { runSlashCommand, slashCommands, type SlashCommandId } from './rich-markdown-slash-commands' function createEditor(content = '/'): Editor { return new Editor({ diff --git a/src/renderer/src/components/editor/rich-markdown-commands.tsx b/src/renderer/src/components/editor/rich-markdown-commands.tsx index 043ba692d87..5da14987ab4 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-commands.tsx @@ -1,32 +1,8 @@ -/* eslint-disable max-lines -- Why: slash commands and doc-link command helpers share trigger-range contracts used by the editor keyboard path. */ -import React from 'react' +import type React from 'react' import type { Editor } from '@tiptap/react' -import { TextSelection } from '@tiptap/pm/state' -import type {} from '@tiptap/extension-mathematics' -import { - ChevronRight, - Heading1, - Heading2, - Heading3, - ImageIcon, - List, - ListOrdered, - Quote, - Sigma, - Table2, - Workflow -} from 'lucide-react' import type { MarkdownDocument } from '../../../../shared/types' import { stripMarkdownExtension } from './markdown-doc-links' -export type SlashMenuState = { - query: string - from: number - to: number - left: number - top: number -} - export type DocLinkMenuState = { query: string // Why: `from` points at the first `[` of the `[[` trigger (not after the brackets) @@ -44,395 +20,6 @@ export type DocLinkMenuRow = | { kind: 'document'; document: MarkdownDocument } | { kind: 'action'; id: string; label: string; run: (editor: Editor) => void } -export type SlashCommandId = - | 'text' - | 'toggle-text' - | 'heading-1' - | 'toggle-h1' - | 'heading-2' - | 'heading-3' - | 'task-list' - | 'bullet-list' - | 'ordered-list' - | 'blockquote' - | 'code-block' - | 'divider' - | 'image' - | 'table' - | 'mermaid' - | 'inline-math' - | 'math-block' - | 'emoji' - -export type SlashCommandIcon = - | { kind: 'component'; component: React.ComponentType<{ className?: string }> } - | { kind: 'text'; value: string } - -export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' - -export type SlashCommand = { - id: SlashCommandId - label: string - aliases: string[] - icon: SlashCommandIcon - group: SlashCommandGroup - description: string - run: (editor: Editor) => void -} - -function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon { - return { kind: 'component', component } -} - -function textIcon(value: string): SlashCommandIcon { - return { kind: 'text', value } -} - -function insertTextWithSelection( - editor: Editor, - text: string, - selectionStartOffset?: number, - selectionEndOffset = selectionStartOffset -): void { - editor.commands.command(({ state, dispatch }) => { - const from = state.selection.from - const tr = state.tr.insertText(text, from, state.selection.to) - - if (selectionStartOffset !== undefined) { - const selectionFrom = from + selectionStartOffset - const selectionTo = from + (selectionEndOffset ?? selectionStartOffset) - tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo)) - } - - dispatch?.(tr.scrollIntoView()) - return true - }) -} - -function insertCodeBlock(editor: Editor, language: string, text: string): void { - editor.commands.command(({ state, dispatch }) => { - const codeBlockType = state.schema.nodes.codeBlock - if (!codeBlockType) { - return false - } - const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined) - const tr = state.tr.replaceSelectionWith(node).scrollIntoView() - const cursor = tr.selection.from + 1 - tr.setSelection(TextSelection.create(tr.doc, cursor, cursor)) - dispatch?.(tr) - return true - }) -} - -function insertToggle(editor: Editor, variant?: 'heading-1'): void { - const insertAt = editor.state.selection.from - - editor - .chain() - .focus() - .insertContentAt(insertAt, { - type: 'details', - attrs: { - open: true, - ...(variant ? { variant } : {}) - }, - content: [ - { - type: 'detailsSummary' - }, - { - type: 'detailsContent', - content: [{ type: 'paragraph' }] - } - ] - }) - .setTextSelection(insertAt + 1) - .run() -} - -/** - * Executes a slash command by first deleting the typed slash text, then - * delegating to the command's run method. Image is special-cased because - * window.prompt() is not supported in Electron's renderer process. - */ -export function runSlashCommand( - editor: Editor, - slashMenu: { from: number; to: number }, - command: SlashCommand, - onImageCommand?: () => void, - onEmojiCommand?: () => void -): void { - editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run() - // Why: image insertion cannot rely on window.prompt() in Electron, so this - // command is rerouted into the editor's local image picker flow. - if (command.id === 'image' && onImageCommand) { - onImageCommand() - return - } - if (command.id === 'emoji' && onEmojiCommand) { - onEmojiCommand() - return - } - command.run(editor) -} - -export const slashCommands: SlashCommand[] = [ - { - id: 'heading-1', - label: 'Heading 1', - aliases: ['h1', 'title'], - icon: icon(Heading1), - group: 'Headings', - description: 'Large section heading.', - run: (editor) => { - // Use setHeading (not toggleHeading) so the slash command is idempotent — - // invoking "/h1" on an existing H1 should keep it as H1, not revert to paragraph. - editor.chain().focus().setHeading({ level: 1 }).run() - } - }, - { - id: 'toggle-h1', - label: 'Toggle Heading 1', - aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], - icon: icon(ChevronRight), - group: 'Headings', - description: 'Create a collapsible section with a large heading summary.', - run: (editor) => { - insertToggle(editor, 'heading-1') - } - }, - { - id: 'heading-2', - label: 'Heading 2', - aliases: ['h2'], - icon: icon(Heading2), - group: 'Headings', - description: 'Medium section heading.', - run: (editor) => { - // Use setHeading (not toggleHeading) so the slash command is idempotent — - // invoking "/h2" on an existing H2 should keep it as H2, not revert to paragraph. - editor.chain().focus().setHeading({ level: 2 }).run() - } - }, - { - id: 'heading-3', - label: 'Heading 3', - aliases: ['h3'], - icon: icon(Heading3), - group: 'Headings', - description: 'Small section heading.', - run: (editor) => { - // Use setHeading (not toggleHeading) so the slash command is idempotent — - // invoking "/h3" on an existing H3 should keep it as H3, not revert to paragraph. - editor.chain().focus().setHeading({ level: 3 }).run() - } - }, - { - id: 'blockquote', - label: 'Quote', - aliases: ['quote', 'blockquote'], - icon: icon(Quote), - group: 'Basic blocks', - description: 'Insert a blockquote.', - run: (editor) => { - editor.chain().focus().toggleBlockquote().run() - } - }, - { - id: 'ordered-list', - label: 'Numbered List', - aliases: ['ordered', 'ol', 'numbered'], - icon: icon(ListOrdered), - group: 'Basic blocks', - description: 'Create an ordered list.', - run: (editor) => { - editor.chain().focus().toggleOrderedList().run() - } - }, - { - id: 'bullet-list', - label: 'Bullet List', - aliases: ['bullet', 'ul', 'list'], - icon: icon(List), - group: 'Basic blocks', - description: 'Create an unordered list.', - run: (editor) => { - editor.chain().focus().toggleBulletList().run() - } - }, - { - id: 'task-list', - label: 'Check List', - aliases: ['todo', 'task', 'checkbox'], - icon: icon(List), - group: 'Basic blocks', - description: 'Create a checklist.', - run: (editor) => { - editor.chain().focus().toggleTaskList().run() - } - }, - { - id: 'text', - label: 'Paragraph', - aliases: ['paragraph', 'plain'], - icon: icon(List), - group: 'Basic blocks', - description: 'Start a normal paragraph.', - run: (editor) => { - editor.chain().focus().setParagraph().run() - } - }, - { - id: 'toggle-text', - label: 'Toggle Text', - aliases: ['toggle', 'details', 'collapse', 'toggle-text'], - icon: icon(ChevronRight), - group: 'Basic blocks', - description: 'Create a collapsible text section.', - run: (editor) => { - insertToggle(editor) - } - }, - { - id: 'code-block', - label: 'Code Block', - aliases: ['code', 'snippet'], - icon: icon(List), - group: 'Basic blocks', - description: 'Insert a fenced code block.', - run: (editor) => { - editor.chain().focus().toggleCodeBlock().run() - } - }, - { - id: 'divider', - label: 'Divider', - aliases: ['divider', 'rule', 'hr'], - icon: icon(List), - group: 'Basic blocks', - description: 'Insert a horizontal rule.', - run: (editor) => { - editor.chain().focus().setHorizontalRule().run() - } - }, - { - id: 'table', - label: 'Table', - aliases: ['grid', 'columns', 'rows'], - icon: icon(Table2), - group: 'Advanced', - description: 'Insert a 3x3 markdown table.', - run: (editor) => { - editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() - } - }, - { - id: 'mermaid', - label: 'Mermaid Diagram', - aliases: ['diagram', 'flowchart', 'chart', 'graph'], - icon: icon(Workflow), - group: 'Advanced', - description: 'Insert a Mermaid fenced block.', - run: (editor) => { - insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]') - } - }, - { - id: 'inline-math', - label: 'Inline Math', - aliases: ['math', 'latex', 'equation', 'formula'], - icon: icon(Sigma), - group: 'Advanced', - description: 'Insert inline LaTeX math.', - run: (editor) => { - editor.commands.insertInlineMath({ latex: 'x' }) - } - }, - { - id: 'math-block', - label: 'Math Block', - aliases: ['display math', 'latex block', 'equation block'], - icon: icon(Sigma), - group: 'Advanced', - description: 'Insert display LaTeX math.', - run: (editor) => { - editor.commands.insertBlockMath({ latex: 'x' }) - } - }, - { - id: 'image', - label: 'Image', - aliases: ['image', 'img'], - icon: icon(ImageIcon), - group: 'Media', - description: 'Insert an image from your computer.', - // Why: window.prompt() is not supported in Electron's renderer process, - // so image URL input is handled by an inline input bar in RichMarkdownEditor. - run: (editor) => { - editor.chain().focus().run() - } - }, - { - id: 'emoji', - label: 'Emoji', - aliases: ['smile', 'reaction', 'icon'], - icon: textIcon('🙂'), - group: 'Others', - description: 'Insert a plain Unicode emoji.', - run: (editor) => { - insertTextWithSelection(editor, '🙂') - } - } -] - -/** - * Inspects the editor selection to decide whether the slash-command menu - * should be open (and where to position it), or dismissed. - */ -export function syncSlashMenu( - editor: Editor, - root: HTMLDivElement | null, - setSlashMenu: React.Dispatch> -): void { - if (!root || editor.view.composing || !editor.isEditable) { - setSlashMenu(null) - return - } - - const { state, view } = editor - const { selection } = state - if (!selection.empty) { - setSlashMenu(null) - return - } - - const { $from } = selection - if (!$from.parent.isTextblock) { - setSlashMenu(null) - return - } - - const blockTextBeforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0') - const slashMatch = blockTextBeforeCursor.match(/^\s*\/([a-z0-9-]*)$/i) - if (!slashMatch) { - setSlashMenu(null) - return - } - - const slashOffset = blockTextBeforeCursor.lastIndexOf('/') - const start = selection.from - ($from.parentOffset - slashOffset) - const coords = view.coordsAtPos(selection.from) - const rect = root.getBoundingClientRect() - - setSlashMenu({ - query: slashMatch[1] ?? '', - from: start, - to: selection.from, - left: coords.left - rect.left, - top: coords.bottom - rect.top + 8 - }) -} - // Why: the leading alternation `(^|[\s(])` is a mid-word guard so typing `foo[[` // inside a word does not fire the popover — `[[` only triggers at start-of-block, // after whitespace, or after `(`. `[^[\]|\r\n]*` bans characters that would break diff --git a/src/renderer/src/components/editor/rich-markdown-context-command-routing.ts b/src/renderer/src/components/editor/rich-markdown-context-command-routing.ts new file mode 100644 index 00000000000..467fc25ecc7 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-context-command-routing.ts @@ -0,0 +1,83 @@ +import type { Editor } from '@tiptap/react' +import type { + RichMarkdownContextMenuCommand, + RichMarkdownContextMenuCommandPayload +} from '../../../../shared/rich-markdown-context-menu' + +export function runRichMarkdownContextCommand({ + command, + editor, + toggleLink, + pickImage +}: { + command: RichMarkdownContextMenuCommand + editor: Editor + toggleLink: () => void + pickImage: () => void +}): void { + switch (command) { + case 'add-link': + toggleLink() + return + case 'bold': + editor.chain().focus().toggleBold().run() + return + case 'italic': + editor.chain().focus().toggleItalic().run() + return + case 'strike': + editor.chain().focus().toggleStrike().run() + return + case 'inline-code': + editor.chain().focus().toggleCode().run() + return + case 'code-block': + editor.chain().focus().toggleCodeBlock().run() + return + case 'blockquote': + editor.chain().focus().toggleBlockquote().run() + return + case 'paragraph': + editor.chain().focus().setParagraph().run() + return + case 'heading-1': + editor.chain().focus().setHeading({ level: 1 }).run() + return + case 'heading-2': + editor.chain().focus().setHeading({ level: 2 }).run() + return + case 'heading-3': + editor.chain().focus().setHeading({ level: 3 }).run() + return + case 'bullet-list': + editor.chain().focus().toggleBulletList().run() + return + case 'ordered-list': + editor.chain().focus().toggleOrderedList().run() + return + case 'task-list': + editor.chain().focus().toggleTaskList().run() + return + case 'image': + pickImage() + return + case 'divider': + editor.chain().focus().setHorizontalRule().run() + } +} + +export function isRichMarkdownContextCommandTarget( + payload: RichMarkdownContextMenuCommandPayload, + root: HTMLElement | null +): boolean { + if (!root) { + return false + } + const rect = root.getBoundingClientRect() + return ( + payload.x >= rect.left && + payload.x <= rect.right && + payload.y >= rect.top && + payload.y <= rect.bottom + ) +} diff --git a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts new file mode 100644 index 00000000000..7be84b360a9 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts @@ -0,0 +1,208 @@ +import type { MutableRefObject } from 'react' +import type { Editor } from '@tiptap/react' +import type { EditorView } from '@tiptap/pm/view' +import { toast } from 'sonner' +import { getConnectionId } from '@/lib/connection-context' +import { openHttpLink } from '@/lib/http-link-routing' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + absolutePathToFileUri as toFileUrlForOsEscape, + resolveMarkdownLinkTarget +} from './markdown-internal-links' +import { scrollToAnchorInEditor } from './markdown-anchor-scroll' +import { getRichMarkdownCommentAtPos } from './rich-markdown-review-annotations' +import type { DiffComment } from '../../../../shared/types' + +export type ActivateMarkdownLink = ( + href: string, + context: { + sourceFilePath: string + worktreeId: string + worktreeRoot: string | null + runtimeEnvironmentId?: string | null + } +) => void | Promise + +export type RichMarkdownRuntimeSettings = Parameters[0] + +type RichMarkdownEditorClickRoutingOptions = { + activateMarkdownLink: ActivateMarkdownLink + editorRef: MutableRefObject + event: MouseEvent + filePath: string + isMac: boolean + markdownCommentsRef: MutableRefObject + markdownSourceLineOffsetRef: MutableRefObject + onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined> + pos: number + rootRef: MutableRefObject + runtimeEnvironmentId?: string | null + scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void + settings: RichMarkdownRuntimeSettings + view: EditorView + worktreeId: string + worktreeRoot: string | null +} + +export function handleRichMarkdownEditorClick({ + activateMarkdownLink, + editorRef, + event, + filePath, + isMac, + markdownCommentsRef, + markdownSourceLineOffsetRef, + onOpenDocLinkRef, + pos, + rootRef, + runtimeEnvironmentId, + scrollRichMarkdownReviewNoteCardIntoView, + settings, + view, + worktreeId, + worktreeRoot +}: RichMarkdownEditorClickRoutingOptions): boolean { + const editor = editorRef.current + const modKey = isMac ? event.metaKey : event.ctrlKey + if (!editor) { + return false + } + if (!modKey) { + const selectedComment = getRichMarkdownCommentAtPos( + editor, + markdownCommentsRef.current, + markdownSourceLineOffsetRef.current, + pos + ) + if (selectedComment) { + scrollRichMarkdownReviewNoteCardIntoView(selectedComment.id) + } + return false + } + const clickedNode = view.state.doc.nodeAt(pos) + if (clickedNode?.type.name === 'image') { + return activateMarkdownImageClick({ + activateMarkdownLink, + filePath, + runtimeEnvironmentId, + src: (clickedNode.attrs.src as string | undefined) ?? '', + worktreeId, + worktreeRoot + }) + } + if (clickedNode?.type.name === 'markdownDocLink') { + onOpenDocLinkRef.current?.(clickedNode.attrs.target as string) + return true + } + const href = getClickedLinkHref(view, pos) + if (!href) { + return false + } + if (href.startsWith('#')) { + scrollToAnchorInEditor(rootRef.current, href.slice(1)) + return true + } + if (event.shiftKey) { + openMarkdownLinkInClientOs({ + href, + filePath, + runtimeEnvironmentId, + settings, + worktreeId, + worktreeRoot + }) + return true + } + void activateMarkdownLink(href, { + sourceFilePath: filePath, + worktreeId, + worktreeRoot, + runtimeEnvironmentId + }) + return true +} + +function activateMarkdownImageClick({ + activateMarkdownLink, + filePath, + runtimeEnvironmentId, + src, + worktreeId, + worktreeRoot +}: { + activateMarkdownLink: ActivateMarkdownLink + filePath: string + runtimeEnvironmentId?: string | null + src: string + worktreeId: string + worktreeRoot: string | null +}): boolean { + if (!src) { + return false + } + void activateMarkdownLink(src, { + sourceFilePath: filePath, + worktreeId, + worktreeRoot, + runtimeEnvironmentId + }) + return true +} + +function getClickedLinkHref(view: EditorView, pos: number): string { + const linkMark = view.state.doc + .resolve(pos) + .marks() + .find((mark) => mark.type.name === 'link') + return linkMark ? (linkMark.attrs.href as string) || '' : '' +} + +function openMarkdownLinkInClientOs({ + href, + filePath, + worktreeId, + worktreeRoot, + runtimeEnvironmentId, + settings +}: { + href: string + filePath: string + worktreeId: string + worktreeRoot: string | null + runtimeEnvironmentId?: string | null + settings: RichMarkdownRuntimeSettings +}): void { + const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot) + if (!classified) { + return + } + if (classified.kind === 'external') { + openHttpLink(classified.url, { forceSystemBrowser: true }) + return + } + if (classified.kind === 'anchor') { + return + } + if ( + isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, runtimeEnvironmentId), { + connectionId: getConnectionId(worktreeId) + }) + ) { + // Why: Shift-click opens through the client OS, which cannot safely resolve + // server-local paths from SSH or remote runtime worktrees. + showLocalPathOpenBlockedToast() + return + } + if (classified.kind === 'markdown') { + void window.api.shell.pathExists(classified.absolutePath).then((exists) => { + if (!exists) { + toast.error(`File not found: ${classified.relativePath}`) + return + } + void window.api.shell.openFileUri(toFileUrlForOsEscape(classified.absolutePath)) + }) + return + } + void window.api.shell.openFileUri(classified.uri) +} diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index a4d12f224f2..2e8f332a09f 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -5,14 +5,12 @@ import { useAppStore } from '@/store' import { isMarkdownPreviewFindShortcut } from './markdown-preview-search' import { editorShortcutMatches } from './editor-shortcuts' import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble' +import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands' import { - commitRow, runSlashCommand, - type DocLinkMenuRow, - type DocLinkMenuState, type SlashCommand, type SlashMenuState -} from './rich-markdown-commands' +} from './rich-markdown-slash-commands' import { collapseEmptyListContinuationParagraph, commitEmptyOrderedListMarkerAsText, diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts new file mode 100644 index 00000000000..f2c9a29a029 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts @@ -0,0 +1,268 @@ +import type { Dispatch, SetStateAction } from 'react' +import type { Editor } from '@tiptap/react' +import type { JSONContent } from '@tiptap/core' +import type { DiffComment } from '../../../../shared/types' +import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight' +import { + getRichMarkdownLineRangeFromBlocks, + getRichMarkdownRangeStart +} from './rich-markdown-range-bounds' +import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' +import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges' + +export type RichMarkdownCommentBlock = { + key: string + startLine: number + endLine: number + from: number + to: number +} + +export type RichMarkdownComposerState = { + lineNumber: number + startLine?: number +} + +export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & { + from: number + to: number + selectedText: string + top: number + left?: number + buttonTop: number + buttonLeft: number +} + +function countMarkdownLines(value: string): number { + if (value.length === 0) { + return 1 + } + return value.split(/\r\n|\r|\n/).length +} + +function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { + return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() +} + +export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { + const jsonContent = editor.getJSON().content ?? [] + const blocks: RichMarkdownCommentBlock[] = [] + let nextLine = 1 + let previousNodeJson: JSONContent | null = null + let previousNodeLineCount = 0 + + editor.state.doc.forEach((node, nodeOffset, index) => { + const nodeJson = jsonContent[index] + if (!nodeJson) { + return + } + const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) + const nodeLineCount = countMarkdownLines(nodeMarkdown) + if (previousNodeJson) { + const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) + const separatorLineCount = Math.max( + 0, + countMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount + ) + nextLine += separatorLineCount + } + const startLine = nextLine + const endLine = Math.max(startLine, startLine + nodeLineCount - 1) + const from = nodeOffset + 1 + blocks.push({ + key: `${index}:${startLine}-${endLine}`, + startLine, + endLine, + from, + to: from + Math.max(0, node.nodeSize - 1) + }) + nextLine = endLine + 1 + previousNodeJson = nodeJson + previousNodeLineCount = nodeLineCount + }) + + if (blocks.length === 0) { + blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) + } + + return blocks +} + +export function clampRichMarkdownAnnotationTarget( + editor: Editor, + target: RichMarkdownAnnotationTarget +): RichMarkdownAnnotationTarget | null { + const maxPos = Math.max(1, editor.state.doc.content.size) + const from = Math.max(1, Math.min(target.from, maxPos)) + const to = Math.max(1, Math.min(target.to, maxPos)) + const clampedFrom = Math.min(from, to) + const clampedTo = Math.max(from, to) + if (clampedFrom === clampedTo) { + return null + } + return { ...target, from: clampedFrom, to: clampedTo } +} + +export function clearRichMarkdownNotePositions( + setNotePositions: Dispatch> +): void { + setNotePositions((current) => (current.length === 0 ? current : [])) +} + +export function getRichMarkdownAnnotationHighlightRanges( + editor: Editor, + comments: readonly DiffComment[], + markdownSourceLineOffset: number +): RichMarkdownAnnotationHighlightRange[] { + return comments.flatMap((comment) => + getRichMarkdownAnnotationHighlightRangesForComment(editor, comment, markdownSourceLineOffset) + ) +} + +export function getRichMarkdownAnnotationHighlightRangesForComment( + editor: Editor, + comment: DiffComment, + markdownSourceLineOffset: number +): RichMarkdownAnnotationHighlightRange[] { + const blocks = buildRichMarkdownCommentBlocks(editor) + const selectedText = comment.selectedText?.trim() + if (!selectedText) { + return [] + } + const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset) + const block = blocks.find( + (candidate) => candidate.startLine <= bodyLineNumber && bodyLineNumber <= candidate.endLine + ) + if (block) { + const blockRanges = findRichMarkdownSelectedTextRanges({ + editor, + selectedText, + from: block.from, + to: block.to + }) + if (blockRanges.length > 0) { + return blockRanges + } + } + return findRichMarkdownSelectedTextRanges({ editor, selectedText }) +} + +export function getRichMarkdownCommentAtPos( + editor: Editor, + comments: readonly DiffComment[], + markdownSourceLineOffset: number, + pos: number +): DiffComment | null { + return ( + comments.find((comment) => + getRichMarkdownAnnotationHighlightRangesForComment( + editor, + comment, + markdownSourceLineOffset + ).some((range) => range.from <= pos && pos <= range.to) + ) ?? null + ) +} + +export function getRichMarkdownCommentAnchorTop( + editor: Editor, + comment: DiffComment, + block: RichMarkdownCommentBlock, + containerRect: DOMRect, + containerScrollTop: number, + markdownSourceLineOffset: number +): number | null { + try { + const ranges = getRichMarkdownAnnotationHighlightRangesForComment( + editor, + comment, + markdownSourceLineOffset + ) + // Why: range notes should sort by the start of the selected text. Anchoring + // to the end puts overlapping ranges with the same final line in creation + // order, so a 43-45 card can render above a 41-45 card. + const anchorPos = getRichMarkdownRangeStart(ranges) ?? block.from + const coords = editor.view.coordsAtPos( + Math.max(1, Math.min(anchorPos, editor.state.doc.content.size)) + ) + return coords.top - containerRect.top + containerScrollTop + } catch { + return null + } +} + +function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerState { + const blocks = buildRichMarkdownCommentBlocks(editor) + const { from, to, empty } = editor.state.selection + const selectedBlocks = empty + ? blocks.filter((block) => block.from <= from && from <= block.to) + : blocks.filter((block) => from <= block.to && to >= block.from) + const targetBlocks = selectedBlocks.length > 0 ? selectedBlocks : [blocks[0]!] + return getRichMarkdownLineRangeFromBlocks(targetBlocks) ?? { lineNumber: 1 } +} + +export function hasRichMarkdownCommentForRange( + comments: readonly DiffComment[], + target: Pick, + markdownSourceLineOffset: number +): boolean { + const startLine = (target.startLine ?? target.lineNumber) + markdownSourceLineOffset + const endLine = target.lineNumber + markdownSourceLineOffset + const selectedText = target.selectedText.trim() + return comments.some((comment) => { + const commentStartLine = comment.startLine ?? comment.lineNumber + return ( + commentStartLine === startLine && + comment.lineNumber === endLine && + (comment.selectedText?.trim() ?? '') === selectedText + ) + }) +} + +function getCurrentRichMarkdownSelectionRect(root: HTMLElement): DOMRect | null { + const selection = window.getSelection() + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return null + } + const range = selection.getRangeAt(0) + if (!root.contains(range.commonAncestorContainer)) { + return null + } + const rect = range.getBoundingClientRect() + if (rect.width > 0 || rect.height > 0) { + return rect + } + return Array.from(range.getClientRects()).find((candidate) => candidate.width > 0) ?? null +} + +export function getRichMarkdownAnnotationTarget( + editor: Editor, + root: HTMLElement +): RichMarkdownAnnotationTarget | null { + if (editor.state.selection.empty) { + return null + } + const rect = getCurrentRichMarkdownSelectionRect(root) + if (!rect) { + return null + } + const selectedText = window.getSelection()?.toString().trim() ?? '' + if (!selectedText) { + return null + } + const rootRect = root.getBoundingClientRect() + const popoverWidth = 420 + const left = Math.max(56, rootRect.width - popoverWidth - 24) + const buttonTop = Math.max(8, rect.bottom - rootRect.top + 6) + const popoverTop = Math.max(8, Math.min(buttonTop + 28, rootRect.height - 220)) + return { + ...getRichMarkdownSelectionRange(editor), + from: editor.state.selection.from, + to: editor.state.selection.to, + selectedText, + top: popoverTop, + left, + buttonTop, + buttonLeft: Math.max(56, rootRect.width - 42) + } +} diff --git a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts new file mode 100644 index 00000000000..d458312f6a1 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts @@ -0,0 +1,65 @@ +import type { Editor } from '@tiptap/react' +import type { DiffComment } from '../../../../shared/types' +import { + buildRichMarkdownCommentBlocks, + getRichMarkdownCommentAnchorTop +} from './rich-markdown-review-annotations' +import { + stackRichMarkdownReviewNotePositions, + type RichMarkdownReviewNotePosition +} from './rich-markdown-review-note-layout' + +type MeasureRichMarkdownReviewNotePositionsOptions = { + container: HTMLDivElement + editor: Editor + markdownComments: DiffComment[] + markdownSourceLineOffset: number +} + +export function measureRichMarkdownReviewNotePositions({ + container, + editor, + markdownComments, + markdownSourceLineOffset +}: MeasureRichMarkdownReviewNotePositionsOptions): RichMarkdownReviewNotePosition[] { + const containerRect = container.getBoundingClientRect() + const blocks = buildRichMarkdownCommentBlocks(editor) + const nextPositions = markdownComments + .map((comment): RichMarkdownReviewNotePosition | null => { + const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset) + const block = blocks.find( + (candidate) => candidate.startLine <= bodyLineNumber && bodyLineNumber <= candidate.endLine + ) + if (!block) { + return null + } + const top = getRichMarkdownCommentAnchorTop( + editor, + comment, + block, + containerRect, + container.scrollTop, + markdownSourceLineOffset + ) + return top === null ? null : { comment, top } + }) + .filter((position): position is RichMarkdownReviewNotePosition => position !== null) + return stackRichMarkdownReviewNotePositions( + nextPositions, + measureReviewNoteHeights(container, nextPositions) + ) +} + +function measureReviewNoteHeights( + container: HTMLDivElement, + positions: RichMarkdownReviewNotePosition[] +): Map { + const measuredHeights = new Map() + for (const pos of positions) { + const el = container.querySelector(`[data-rich-markdown-review-note-id="${pos.comment.id}"]`) + if (el) { + measuredHeights.set(pos.comment.id, el.getBoundingClientRect().height) + } + } + return measuredHeights +} diff --git a/src/renderer/src/components/editor/rich-markdown-review-text-ranges.ts b/src/renderer/src/components/editor/rich-markdown-review-text-ranges.ts new file mode 100644 index 00000000000..0cc08c32a92 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-review-text-ranges.ts @@ -0,0 +1,96 @@ +import type { Editor } from '@tiptap/react' +import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight' + +type RichMarkdownTextChar = { + value: string + pos: number | null +} + +function normalizeRichMarkdownTextWithPositions( + chars: RichMarkdownTextChar[] +): RichMarkdownTextChar[] { + const normalized: RichMarkdownTextChar[] = [] + let previousWasWhitespace = false + for (const char of chars) { + if (/\s/.test(char.value)) { + if (!previousWasWhitespace) { + normalized.push({ value: ' ', pos: char.pos }) + } + previousWasWhitespace = true + continue + } + normalized.push(char) + previousWasWhitespace = false + } + return normalized +} + +function collectRichMarkdownTextChars( + editor: Editor, + from = 0, + to = editor.state.doc.content.size +): RichMarkdownTextChar[] { + const chars: RichMarkdownTextChar[] = [] + editor.state.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isText || !node.text) { + return + } + if (chars.length > 0) { + chars.push({ value: ' ', pos: null }) + } + for (let index = 0; index < node.text.length; index += 1) { + chars.push({ value: node.text[index], pos: pos + index }) + } + }) + return chars +} + +export function findRichMarkdownSelectedTextRanges({ + editor, + selectedText, + from, + to +}: { + editor: Editor + selectedText: string + from?: number + to?: number +}): RichMarkdownAnnotationHighlightRange[] { + const normalizedChars = normalizeRichMarkdownTextWithPositions( + collectRichMarkdownTextChars(editor, from, to) + ) + const haystack = normalizedChars.map((char) => char.value).join('') + const needle = normalizeRichMarkdownTextWithPositions( + Array.from(selectedText).map((value) => ({ value, pos: null })) + ) + .map((char) => char.value) + .join('') + const start = haystack.indexOf(needle) + if (start === -1) { + return [] + } + + const positions = normalizedChars + .slice(start, start + needle.length) + .map((char) => char.pos) + .filter((pos): pos is number => pos !== null) + .sort((left, right) => left - right) + if (positions.length === 0) { + return [] + } + + const ranges: RichMarkdownAnnotationHighlightRange[] = [] + let rangeFrom = positions[0] + let rangeTo = positions[0] + 1 + for (const pos of positions.slice(1)) { + if (pos === rangeTo) { + rangeTo += 1 + continue + } + ranges.push({ from: rangeFrom, to: rangeTo }) + rangeFrom = pos + rangeTo = pos + 1 + } + ranges.push({ from: rangeFrom, to: rangeTo }) + return ranges +} diff --git a/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx b/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx new file mode 100644 index 00000000000..885cf68db61 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx @@ -0,0 +1,414 @@ +import type React from 'react' +import type { Editor } from '@tiptap/react' +import { TextSelection } from '@tiptap/pm/state' +import type {} from '@tiptap/extension-mathematics' +import { + ChevronRight, + Heading1, + Heading2, + Heading3, + ImageIcon, + List, + ListOrdered, + Quote, + Sigma, + Table2, + Workflow +} from 'lucide-react' + +export type SlashMenuState = { + query: string + from: number + to: number + left: number + top: number +} + +export type SlashCommandId = + | 'text' + | 'toggle-text' + | 'heading-1' + | 'toggle-h1' + | 'heading-2' + | 'heading-3' + | 'task-list' + | 'bullet-list' + | 'ordered-list' + | 'blockquote' + | 'code-block' + | 'divider' + | 'image' + | 'table' + | 'mermaid' + | 'inline-math' + | 'math-block' + | 'emoji' + +export type SlashCommandIcon = + | { kind: 'component'; component: React.ComponentType<{ className?: string }> } + | { kind: 'text'; value: string } + +export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' + +export type SlashCommand = { + id: SlashCommandId + label: string + aliases: string[] + icon: SlashCommandIcon + group: SlashCommandGroup + description: string + run: (editor: Editor) => void +} + +function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon { + return { kind: 'component', component } +} + +function textIcon(value: string): SlashCommandIcon { + return { kind: 'text', value } +} + +function insertTextWithSelection( + editor: Editor, + text: string, + selectionStartOffset?: number, + selectionEndOffset = selectionStartOffset +): void { + editor.commands.command(({ state, dispatch }) => { + const from = state.selection.from + const tr = state.tr.insertText(text, from, state.selection.to) + + if (selectionStartOffset !== undefined) { + const selectionFrom = from + selectionStartOffset + const selectionTo = from + (selectionEndOffset ?? selectionStartOffset) + tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo)) + } + + dispatch?.(tr.scrollIntoView()) + return true + }) +} + +function insertCodeBlock(editor: Editor, language: string, text: string): void { + editor.commands.command(({ state, dispatch }) => { + const codeBlockType = state.schema.nodes.codeBlock + if (!codeBlockType) { + return false + } + const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined) + const tr = state.tr.replaceSelectionWith(node).scrollIntoView() + const cursor = tr.selection.from + 1 + tr.setSelection(TextSelection.create(tr.doc, cursor, cursor)) + dispatch?.(tr) + return true + }) +} + +function insertToggle(editor: Editor, variant?: 'heading-1'): void { + const insertAt = editor.state.selection.from + + editor + .chain() + .focus() + .insertContentAt(insertAt, { + type: 'details', + attrs: { + open: true, + ...(variant ? { variant } : {}) + }, + content: [ + { + type: 'detailsSummary' + }, + { + type: 'detailsContent', + content: [{ type: 'paragraph' }] + } + ] + }) + .setTextSelection(insertAt + 1) + .run() +} + +/** + * Executes a slash command by first deleting the typed slash text, then + * delegating to the command's run method. Image is special-cased because + * window.prompt() is not supported in Electron's renderer process. + */ +export function runSlashCommand( + editor: Editor, + slashMenu: { from: number; to: number }, + command: SlashCommand, + onImageCommand?: () => void, + onEmojiCommand?: () => void +): void { + editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run() + // Why: image insertion cannot rely on window.prompt() in Electron, so this + // command is rerouted into the editor's local image picker flow. + if (command.id === 'image' && onImageCommand) { + onImageCommand() + return + } + if (command.id === 'emoji' && onEmojiCommand) { + onEmojiCommand() + return + } + command.run(editor) +} + +export const slashCommands: SlashCommand[] = [ + { + id: 'heading-1', + label: 'Heading 1', + aliases: ['h1', 'title'], + icon: icon(Heading1), + group: 'Headings', + description: 'Large section heading.', + run: (editor) => { + // Use setHeading (not toggleHeading) so the slash command is idempotent — + // invoking "/h1" on an existing H1 should keep it as H1, not revert to paragraph. + editor.chain().focus().setHeading({ level: 1 }).run() + } + }, + { + id: 'toggle-h1', + label: 'Toggle Heading 1', + aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], + icon: icon(ChevronRight), + group: 'Headings', + description: 'Create a collapsible section with a large heading summary.', + run: (editor) => { + insertToggle(editor, 'heading-1') + } + }, + { + id: 'heading-2', + label: 'Heading 2', + aliases: ['h2'], + icon: icon(Heading2), + group: 'Headings', + description: 'Medium section heading.', + run: (editor) => { + // Use setHeading (not toggleHeading) so the slash command is idempotent — + // invoking "/h2" on an existing H2 should keep it as H2, not revert to paragraph. + editor.chain().focus().setHeading({ level: 2 }).run() + } + }, + { + id: 'heading-3', + label: 'Heading 3', + aliases: ['h3'], + icon: icon(Heading3), + group: 'Headings', + description: 'Small section heading.', + run: (editor) => { + // Use setHeading (not toggleHeading) so the slash command is idempotent — + // invoking "/h3" on an existing H3 should keep it as H3, not revert to paragraph. + editor.chain().focus().setHeading({ level: 3 }).run() + } + }, + { + id: 'blockquote', + label: 'Quote', + aliases: ['quote', 'blockquote'], + icon: icon(Quote), + group: 'Basic blocks', + description: 'Insert a blockquote.', + run: (editor) => { + editor.chain().focus().toggleBlockquote().run() + } + }, + { + id: 'ordered-list', + label: 'Numbered List', + aliases: ['ordered', 'ol', 'numbered'], + icon: icon(ListOrdered), + group: 'Basic blocks', + description: 'Create an ordered list.', + run: (editor) => { + editor.chain().focus().toggleOrderedList().run() + } + }, + { + id: 'bullet-list', + label: 'Bullet List', + aliases: ['bullet', 'ul', 'list'], + icon: icon(List), + group: 'Basic blocks', + description: 'Create an unordered list.', + run: (editor) => { + editor.chain().focus().toggleBulletList().run() + } + }, + { + id: 'task-list', + label: 'Check List', + aliases: ['todo', 'task', 'checkbox'], + icon: icon(List), + group: 'Basic blocks', + description: 'Create a checklist.', + run: (editor) => { + editor.chain().focus().toggleTaskList().run() + } + }, + { + id: 'text', + label: 'Paragraph', + aliases: ['paragraph', 'plain'], + icon: icon(List), + group: 'Basic blocks', + description: 'Start a normal paragraph.', + run: (editor) => { + editor.chain().focus().setParagraph().run() + } + }, + { + id: 'toggle-text', + label: 'Toggle Text', + aliases: ['toggle', 'details', 'collapse', 'toggle-text'], + icon: icon(ChevronRight), + group: 'Basic blocks', + description: 'Create a collapsible text section.', + run: (editor) => { + insertToggle(editor) + } + }, + { + id: 'code-block', + label: 'Code Block', + aliases: ['code', 'snippet'], + icon: icon(List), + group: 'Basic blocks', + description: 'Insert a fenced code block.', + run: (editor) => { + editor.chain().focus().toggleCodeBlock().run() + } + }, + { + id: 'divider', + label: 'Divider', + aliases: ['divider', 'rule', 'hr'], + icon: icon(List), + group: 'Basic blocks', + description: 'Insert a horizontal rule.', + run: (editor) => { + editor.chain().focus().setHorizontalRule().run() + } + }, + { + id: 'table', + label: 'Table', + aliases: ['grid', 'columns', 'rows'], + icon: icon(Table2), + group: 'Advanced', + description: 'Insert a 3x3 markdown table.', + run: (editor) => { + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() + } + }, + { + id: 'mermaid', + label: 'Mermaid Diagram', + aliases: ['diagram', 'flowchart', 'chart', 'graph'], + icon: icon(Workflow), + group: 'Advanced', + description: 'Insert a Mermaid fenced block.', + run: (editor) => { + insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]') + } + }, + { + id: 'inline-math', + label: 'Inline Math', + aliases: ['math', 'latex', 'equation', 'formula'], + icon: icon(Sigma), + group: 'Advanced', + description: 'Insert inline LaTeX math.', + run: (editor) => { + editor.commands.insertInlineMath({ latex: 'x' }) + } + }, + { + id: 'math-block', + label: 'Math Block', + aliases: ['display math', 'latex block', 'equation block'], + icon: icon(Sigma), + group: 'Advanced', + description: 'Insert display LaTeX math.', + run: (editor) => { + editor.commands.insertBlockMath({ latex: 'x' }) + } + }, + { + id: 'image', + label: 'Image', + aliases: ['image', 'img'], + icon: icon(ImageIcon), + group: 'Media', + description: 'Insert an image from your computer.', + // Why: window.prompt() is not supported in Electron's renderer process, + // so image URL input is handled by an inline input bar in RichMarkdownEditor. + run: (editor) => { + editor.chain().focus().run() + } + }, + { + id: 'emoji', + label: 'Emoji', + aliases: ['smile', 'reaction', 'icon'], + icon: textIcon('🙂'), + group: 'Others', + description: 'Insert a plain Unicode emoji.', + run: (editor) => { + insertTextWithSelection(editor, '🙂') + } + } +] + +/** + * Inspects the editor selection to decide whether the slash-command menu + * should be open (and where to position it), or dismissed. + */ +export function syncSlashMenu( + editor: Editor, + root: HTMLDivElement | null, + setSlashMenu: React.Dispatch> +): void { + if (!root || editor.view.composing || !editor.isEditable) { + setSlashMenu(null) + return + } + + const { state, view } = editor + const { selection } = state + if (!selection.empty) { + setSlashMenu(null) + return + } + + const { $from } = selection + if (!$from.parent.isTextblock) { + setSlashMenu(null) + return + } + + const blockTextBeforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0') + const slashMatch = blockTextBeforeCursor.match(/^\s*\/([a-z0-9-]*)$/i) + if (!slashMatch) { + setSlashMenu(null) + return + } + + const slashOffset = blockTextBeforeCursor.lastIndexOf('/') + const start = selection.from - ($from.parentOffset - slashOffset) + const coords = view.coordsAtPos(selection.from) + const rect = root.getBoundingClientRect() + + setSlashMenu({ + query: slashMatch[1] ?? '', + from: start, + to: selection.from, + left: coords.left - rect.left, + top: coords.bottom - rect.top + 8 + }) +} diff --git a/src/renderer/src/components/editor/useDiffSectionLayoutMetrics.ts b/src/renderer/src/components/editor/useDiffSectionLayoutMetrics.ts new file mode 100644 index 00000000000..a2bc58ad24d --- /dev/null +++ b/src/renderer/src/components/editor/useDiffSectionLayoutMetrics.ts @@ -0,0 +1,51 @@ +import { useMemo } from 'react' +import { computeLineStats } from './diff-line-stats' +import { getDiffSectionBodyHeight, isIntrinsicHeightImageDiff } from './diff-section-layout' +import type { DiffSection } from './diff-section-types' + +export function useDiffSectionLayoutMetrics({ + section, + sectionHeight +}: { + section: DiffSection + sectionHeight: number | undefined +}): { + lineStats: ReturnType | null + sectionBodyHeight: number | undefined + useIntrinsicImageHeight: boolean +} { + const lineStats = useMemo( + () => + section.loading || section.error + ? null + : computeLineStats(section.originalContent, section.modifiedContent, section.status), + [ + section.error, + section.loading, + section.originalContent, + section.modifiedContent, + section.status + ] + ) + const changedLineCount = useMemo(() => { + if (lineStats) { + return lineStats.added + lineStats.removed + } + if (section.added === undefined && section.removed === undefined) { + return undefined + } + return (section.added ?? 0) + (section.removed ?? 0) + }, [lineStats, section.added, section.removed]) + // Why: image diffs need document-flow height in the combined view; the text + // fallback only knows line counts and would squash screenshots into one row. + const useIntrinsicImageHeight = isIntrinsicHeightImageDiff(section.diffResult) + const sectionBodyHeight = getDiffSectionBodyHeight({ + measuredContentHeight: sectionHeight, + originalContent: section.originalContent, + modifiedContent: section.modifiedContent, + changedLineCount, + useIntrinsicImageHeight + }) + + return { lineStats, sectionBodyHeight, useIntrinsicImageHeight } +} diff --git a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts new file mode 100644 index 00000000000..2276908f443 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts @@ -0,0 +1,290 @@ +import { type Dispatch, type MutableRefObject, type SetStateAction, useMemo } from 'react' +import { useEditor, type Editor } from '@tiptap/react' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler' +import { handleRichMarkdownCut } from './rich-markdown-cut-handler' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { autoFocusRichEditor } from './rich-markdown-auto-focus' +import { + syncDocLinkMenu, + type DocLinkMenuRow, + type DocLinkMenuState +} from './rich-markdown-commands' +import { + syncSlashMenu, + type SlashCommand, + type SlashMenuState +} from './rich-markdown-slash-commands' +import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation' +import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble' +import { + handleRichMarkdownEditorClick, + type ActivateMarkdownLink, + type RichMarkdownRuntimeSettings +} from './rich-markdown-editor-click-routing' +import type { DiffComment } from '../../../../shared/types' + +const richMarkdownExtensions = createRichMarkdownExtensions({ includePlaceholder: true }) + +export function useRichMarkdownEditorInstance({ + content, + filePath, + worktreeId, + worktreeRoot, + runtimeEnvironmentId, + isMac, + settings, + activateMarkdownLink, + rootRef, + editorRef, + lastCommittedMarkdownRef, + onContentChangeRef, + onDirtyStateHintRef, + onSaveRef, + onOpenDocLinkRef, + isEditingLinkRef, + slashMenuRef, + filteredSlashCommandsRef, + selectedCommandIndexRef, + docLinkMenuRef, + filteredDocLinkRowsRef, + selectedDocLinkIndexRef, + handleLocalImagePickRef, + handleEmojiPickRef, + typedEmptyOrderedListMarkerRef, + cancelAutoFocusRef, + serializeTimerRef, + isInitializingRef, + isApplyingProgrammaticUpdateRef, + markdownCommentsRef, + markdownSourceLineOffsetRef, + flushPendingSerialization, + openSearchRef, + syncAnnotationTarget, + clearAnnotationTarget, + scrollRichMarkdownReviewNoteCardIntoView, + setIsEditingLink, + setLinkBubble, + setSelectedCommandIndex, + setSelectedDocLinkIndex, + setSlashMenu, + setDocLinkMenu +}: { + content: string + filePath: string + worktreeId: string + worktreeRoot: string | null + runtimeEnvironmentId?: string | null + isMac: boolean + settings: RichMarkdownRuntimeSettings + activateMarkdownLink: ActivateMarkdownLink + rootRef: MutableRefObject + editorRef: MutableRefObject + lastCommittedMarkdownRef: MutableRefObject + onContentChangeRef: MutableRefObject<(content: string) => void> + onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void> + onSaveRef: MutableRefObject<(content: string) => void> + onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined> + isEditingLinkRef: MutableRefObject + slashMenuRef: MutableRefObject + filteredSlashCommandsRef: MutableRefObject + selectedCommandIndexRef: MutableRefObject + docLinkMenuRef: MutableRefObject + filteredDocLinkRowsRef: MutableRefObject + selectedDocLinkIndexRef: MutableRefObject + handleLocalImagePickRef: MutableRefObject<() => void> + handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void> + typedEmptyOrderedListMarkerRef: MutableRefObject + cancelAutoFocusRef: MutableRefObject<(() => void) | null> + serializeTimerRef: MutableRefObject + isInitializingRef: MutableRefObject + isApplyingProgrammaticUpdateRef: MutableRefObject + markdownCommentsRef: MutableRefObject + markdownSourceLineOffsetRef: MutableRefObject + flushPendingSerialization: () => void + openSearchRef: MutableRefObject<() => void> + syncAnnotationTarget: (editor: Editor) => void + clearAnnotationTarget: () => void + scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void + setIsEditingLink: Dispatch> + setLinkBubble: Dispatch> + setSelectedCommandIndex: Dispatch> + setSelectedDocLinkIndex: Dispatch> + setSlashMenu: Dispatch> + setDocLinkMenu: Dispatch> +}): Editor | null { + const editor = useEditor( + useMemo( + () => ({ + immediatelyRender: false, + extensions: richMarkdownExtensions, + content: encodeRawMarkdownHtmlForRichEditor(content), + contentType: 'markdown' as const, + editorProps: { + attributes: { + class: 'rich-markdown-editor', + spellcheck: 'true' + }, + handleDOMEvents: { + cut: handleRichMarkdownCut + }, + handleTextInput: (view, from, to, text) => { + typedEmptyOrderedListMarkerRef.current = false + if (text !== ' ' || from !== to || !view.state.selection.empty) { + return false + } + const { $from } = view.state.selection + const beforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0') + typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor) + return false + }, + handleKeyDown: createRichMarkdownKeyHandler({ + isMac, + editorRef, + rootRef, + lastCommittedMarkdownRef, + onContentChangeRef, + onSaveRef, + isEditingLinkRef, + slashMenuRef, + filteredSlashCommandsRef, + selectedCommandIndexRef, + docLinkMenuRef, + filteredDocLinkRowsRef, + selectedDocLinkIndexRef, + handleLocalImagePickRef, + handleEmojiPickRef, + typedEmptyOrderedListMarkerRef, + flushPendingSerialization, + openSearchRef, + setIsEditingLink, + setLinkBubble, + setSelectedCommandIndex, + setSelectedDocLinkIndex, + setSlashMenu, + setDocLinkMenu + }), + handleClick: (view, pos, event) => { + return handleRichMarkdownEditorClick({ + activateMarkdownLink, + editorRef, + event, + filePath, + isMac, + markdownCommentsRef, + markdownSourceLineOffsetRef, + onOpenDocLinkRef, + pos, + rootRef, + runtimeEnvironmentId, + scrollRichMarkdownReviewNoteCardIntoView, + settings, + view, + worktreeId, + worktreeRoot + }) + } + }, + onFocus: () => { + window.api.ui.setMarkdownEditorFocused(true) + }, + onBlur: () => { + window.api.ui.setMarkdownEditorFocused(false) + clearAnnotationTarget() + }, + onCreate: ({ editor: nextEditor }) => { + normalizeSoftBreaks(nextEditor) + lastCommittedMarkdownRef.current = content + isInitializingRef.current = false + cancelAutoFocusRef.current?.() + cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current) + }, + onUpdate: ({ editor: nextEditor }) => { + syncSlashMenu(nextEditor, rootRef.current, setSlashMenu) + syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu) + if (!isSingleEmptyTopLevelOrderedList(nextEditor)) { + typedEmptyOrderedListMarkerRef.current = false + } + if (isInitializingRef.current || isApplyingProgrammaticUpdateRef.current) { + return + } + onDirtyStateHintRef.current(true) + if (serializeTimerRef.current !== null) { + window.clearTimeout(serializeTimerRef.current) + } + serializeTimerRef.current = window.setTimeout(() => { + serializeTimerRef.current = null + try { + const markdown = nextEditor.getMarkdown() + lastCommittedMarkdownRef.current = markdown + onContentChangeRef.current(markdown) + } catch { + // Why: save/restart flows should never crash the UI just because + // the editor was torn down between scheduling and serializing. + } + }, 300) + }, + onSelectionUpdate: ({ editor: nextEditor }) => { + syncSlashMenu(nextEditor, rootRef.current, setSlashMenu) + syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu) + syncAnnotationTarget(nextEditor) + setIsEditingLink(false) + if (nextEditor.isActive('link')) { + const attrs = nextEditor.getAttributes('link') + const pos = getLinkBubblePosition(nextEditor, rootRef.current) + setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null) + } else { + setLinkBubble(null) + } + } + }), + [ + activateMarkdownLink, + cancelAutoFocusRef, + clearAnnotationTarget, + content, + docLinkMenuRef, + editorRef, + filePath, + filteredDocLinkRowsRef, + filteredSlashCommandsRef, + flushPendingSerialization, + handleEmojiPickRef, + handleLocalImagePickRef, + isApplyingProgrammaticUpdateRef, + isEditingLinkRef, + isInitializingRef, + isMac, + lastCommittedMarkdownRef, + markdownCommentsRef, + markdownSourceLineOffsetRef, + onContentChangeRef, + onDirtyStateHintRef, + onOpenDocLinkRef, + onSaveRef, + openSearchRef, + rootRef, + runtimeEnvironmentId, + scrollRichMarkdownReviewNoteCardIntoView, + selectedCommandIndexRef, + selectedDocLinkIndexRef, + serializeTimerRef, + setDocLinkMenu, + setIsEditingLink, + setLinkBubble, + setSelectedCommandIndex, + setSelectedDocLinkIndex, + setSlashMenu, + settings, + slashMenuRef, + syncAnnotationTarget, + typedEmptyOrderedListMarkerRef, + worktreeId, + worktreeRoot + ] + ) + ) + editorRef.current = editor ?? null + return editor +} diff --git a/src/renderer/src/components/editor/useRichMarkdownMenuController.ts b/src/renderer/src/components/editor/useRichMarkdownMenuController.ts new file mode 100644 index 00000000000..43d568c1a7c --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownMenuController.ts @@ -0,0 +1,154 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type React from 'react' +import type { MarkdownDocument } from '../../../../shared/types' +import { getMarkdownDocCompletionDocuments } from './markdown-doc-completions' +import type { DocLinkMenuRow, DocLinkMenuState } from './rich-markdown-commands' +import { + slashCommands, + type SlashCommand, + type SlashMenuState +} from './rich-markdown-slash-commands' + +const DOC_LINK_MENU_MAX_ROWS = 20 + +type MenuSelection = { + index: number + query: string | null +} + +export function useRichMarkdownMenuController({ + markdownDocuments +}: { + markdownDocuments?: MarkdownDocument[] +}) { + const [slashMenu, setSlashMenu] = useState(null) + const [slashSelection, setSlashSelection] = useState({ query: null, index: 0 }) + const [docLinkMenu, setDocLinkMenu] = useState(null) + const [docLinkSelection, setDocLinkSelection] = useState({ query: null, index: 0 }) + const [emojiMenu, setEmojiMenu] = useState<{ left: number; top: number } | null>(null) + const slashMenuRef = useRef(null) + const filteredSlashCommandsRef = useRef(slashCommands) + const selectedCommandIndexRef = useRef(0) + const docLinkMenuRef = useRef(null) + const filteredDocLinkRowsRef = useRef([]) + const selectedDocLinkIndexRef = useRef(0) + const handleEmojiPickRef = useRef<(menu: SlashMenuState) => void>(() => {}) + + slashMenuRef.current = slashMenu + docLinkMenuRef.current = docLinkMenu + + const setSelectedCommandIndex = useCallback>>( + (nextIndex) => { + setSlashSelection((current) => { + const query = slashMenuRef.current?.query ?? null + const optionCount = filteredSlashCommandsRef.current.length + const currentIndex = + current.query === query ? clampMenuSelectionIndex(current.index, optionCount) : 0 + const resolvedIndex = typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex + return { + query, + index: clampMenuSelectionIndex(resolvedIndex, optionCount) + } + }) + }, + [] + ) + + const setSelectedDocLinkIndex = useCallback>>( + (nextIndex) => { + setDocLinkSelection((current) => { + const query = docLinkMenuRef.current?.query ?? null + const rowCount = filteredDocLinkRowsRef.current.length + const currentIndex = + current.query === query ? clampMenuSelectionIndex(current.index, rowCount) : 0 + const resolvedIndex = typeof nextIndex === 'function' ? nextIndex(currentIndex) : nextIndex + return { + query, + index: clampMenuSelectionIndex(resolvedIndex, rowCount) + } + }) + }, + [] + ) + + const filteredSlashCommands = useMemo(() => { + const query = slashMenu?.query.trim().toLowerCase() ?? '' + if (!query) { + return slashCommands + } + return slashCommands.filter((command) => { + const haystack = [command.label, ...command.aliases].join(' ').toLowerCase() + return haystack.includes(query) + }) + }, [slashMenu?.query]) + const selectedCommandIndex = resolveSelectedMenuIndex( + slashSelection, + slashMenu?.query ?? null, + filteredSlashCommands.length + ) + filteredSlashCommandsRef.current = filteredSlashCommands + selectedCommandIndexRef.current = selectedCommandIndex + + const { docLinkRows, docLinkTotalMatches } = useMemo(() => { + if (!docLinkMenu || !markdownDocuments) { + return { docLinkRows: [] as DocLinkMenuRow[], docLinkTotalMatches: 0 } + } + const matches = getMarkdownDocCompletionDocuments(markdownDocuments, docLinkMenu.query) + const rows: DocLinkMenuRow[] = matches + .slice(0, DOC_LINK_MENU_MAX_ROWS) + .map((document) => ({ kind: 'document', document })) + return { docLinkRows: rows, docLinkTotalMatches: matches.length } + }, [docLinkMenu, markdownDocuments]) + const selectedDocLinkIndex = resolveSelectedMenuIndex( + docLinkSelection, + docLinkMenu?.query ?? null, + docLinkRows.length + ) + filteredDocLinkRowsRef.current = docLinkRows + selectedDocLinkIndexRef.current = selectedDocLinkIndex + + const openEmojiMenu = useCallback((menu: SlashMenuState): void => { + setSlashMenu(null) + setEmojiMenu({ left: menu.left, top: menu.top }) + }, []) + handleEmojiPickRef.current = openEmojiMenu + + return { + docLinkMenu, + docLinkRows, + docLinkTotalMatches, + docLinkMenuRef, + emojiMenu, + filteredDocLinkRowsRef, + filteredSlashCommands, + filteredSlashCommandsRef, + handleEmojiPickRef, + openEmojiMenu, + selectedCommandIndex, + selectedCommandIndexRef, + selectedDocLinkIndex, + selectedDocLinkIndexRef, + setDocLinkMenu, + setEmojiMenu, + setSelectedCommandIndex, + setSelectedDocLinkIndex, + setSlashMenu, + slashMenu, + slashMenuRef + } +} + +function resolveSelectedMenuIndex( + selection: MenuSelection, + query: string | null, + itemCount: number +): number { + return selection.query === query ? clampMenuSelectionIndex(selection.index, itemCount) : 0 +} + +function clampMenuSelectionIndex(index: number, itemCount: number): number { + if (itemCount <= 0) { + return 0 + } + return Math.min(Math.max(index, 0), itemCount - 1) +} diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts new file mode 100644 index 00000000000..0b6f33e14a0 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -0,0 +1,156 @@ +import { useEffect } from 'react' +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { Editor } from '@tiptap/react' +import { getConnectionId } from '@/lib/connection-context' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import type { MarkdownDocument } from '../../../../shared/types' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { syncDocLinkMenu, type DocLinkMenuState } from './rich-markdown-commands' +import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands' + +type RichMarkdownProgrammaticSyncOptions = { + content: string + docLinkMenuSetter: Dispatch> + editor: Editor | null + fileId: string + filePath: string + isApplyingProgrammaticUpdateRef: MutableRefObject + lastCommittedMarkdownRef: MutableRefObject + markdownDocuments?: MarkdownDocument[] + rootRef: MutableRefObject + runtimeEnvironmentId?: string | null + settings: Parameters[0] + slashMenuSetter: Dispatch> + worktreeId: string + worktreeRoot: string | null +} + +type RichMarkdownEditorStorage = { + image: { + filePath: string + runtimeContext?: { + connectionId: string | null | undefined + settings: ReturnType + worktreeId: string + worktreePath: string + } + } + markdownDocLink: { + documents: MarkdownDocument[] + } +} + +export function useRichMarkdownProgrammaticSync({ + content, + docLinkMenuSetter, + editor, + fileId, + filePath, + isApplyingProgrammaticUpdateRef, + lastCommittedMarkdownRef, + markdownDocuments, + rootRef, + runtimeEnvironmentId, + settings, + slashMenuSetter, + worktreeId, + worktreeRoot +}: RichMarkdownProgrammaticSyncOptions): void { + useEffect(() => { + if (!editor) { + return + } + isApplyingProgrammaticUpdateRef.current = true + try { + const storage = editor.storage as unknown as RichMarkdownEditorStorage + storage.image.filePath = filePath + storage.image.runtimeContext = worktreeRoot + ? { + settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), + worktreeId, + worktreePath: worktreeRoot, + connectionId: getConnectionId(worktreeId) + } + : undefined + editor.view.dispatch(editor.state.tr) + } finally { + isApplyingProgrammaticUpdateRef.current = false + } + }, [ + editor, + filePath, + isApplyingProgrammaticUpdateRef, + runtimeEnvironmentId, + settings, + worktreeId, + worktreeRoot + ]) + + useEffect(() => { + if (!editor || !markdownDocuments) { + return + } + isApplyingProgrammaticUpdateRef.current = true + try { + const storage = editor.storage as unknown as RichMarkdownEditorStorage + storage.markdownDocLink.documents = markdownDocuments + editor.view.dispatch(editor.state.tr.setMeta('docLinksUpdated', true)) + } finally { + isApplyingProgrammaticUpdateRef.current = false + } + }, [editor, isApplyingProgrammaticUpdateRef, markdownDocuments]) + + useEffect(() => { + if (!editor) { + return + } + if (content === lastCommittedMarkdownRef.current || editor.getMarkdown() === content) { + return + } + isApplyingProgrammaticUpdateRef.current = true + try { + applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef) + } finally { + isApplyingProgrammaticUpdateRef.current = false + } + syncSlashMenu(editor, rootRef.current, slashMenuSetter) + syncDocLinkMenu(editor, rootRef.current, docLinkMenuSetter) + }, [ + content, + docLinkMenuSetter, + editor, + fileId, + isApplyingProgrammaticUpdateRef, + lastCommittedMarkdownRef, + rootRef, + slashMenuSetter + ]) +} + +function applyExternalRichMarkdownContent( + editor: Editor, + content: string, + lastCommittedMarkdownRef: MutableRefObject +): void { + try { + const hadFocus = editor.isFocused + const { from: prevFrom, to: prevTo } = editor.state.selection + editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(content), { + contentType: 'markdown', + emitUpdate: false + }) + normalizeSoftBreaks(editor) + lastCommittedMarkdownRef.current = content + if (hadFocus) { + const docSize = editor.state.doc.content.size + editor + .chain() + .setTextSelection({ from: Math.min(prevFrom, docSize), to: Math.min(prevTo, docSize) }) + .focus() + .run() + } + } catch (err) { + console.error('[RichMarkdownEditor] failed to apply external content update', err) + } +} diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts new file mode 100644 index 00000000000..43bade891b8 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts @@ -0,0 +1,280 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { MutableRefObject } from 'react' +import type { Editor } from '@tiptap/react' +import type { AppState } from '@/store' +import { richMarkdownAnnotationHighlightPluginKey } from './rich-markdown-annotation-highlight' +import { updateRichMarkdownAnnotationHighlightsAfterSubmit } from './rich-markdown-annotation-submit-highlights' +import { + clampRichMarkdownAnnotationTarget, + getRichMarkdownAnnotationTarget, + hasRichMarkdownCommentForRange, + type RichMarkdownAnnotationTarget +} from './rich-markdown-review-annotations' +import { shouldExpandRichMarkdownReviewRail } from './rich-markdown-review-note-layout' +import { useRichMarkdownReviewData } from './useRichMarkdownReviewData' +import { useRichMarkdownReviewCopyFeedback } from './useRichMarkdownReviewCopyFeedback' +import { useRichMarkdownReviewRailController } from './useRichMarkdownReviewRailController' +import type { DiffComment } from '../../../../shared/types' + +type UseRichMarkdownReviewControllerOptions = { + addDiffComment: AppState['addDiffComment'] + allDiffComments: DiffComment[] | undefined + content: string + editorRef: MutableRefObject + filePath: string + markdownAnnotationFilePath?: string + markdownAnnotationsEnabled: boolean + markdownReviewContent: string + markdownSourceLineOffset: number + rootRef: MutableRefObject + scrollContainerRef: MutableRefObject + worktreeId: string + worktreeRoot: string | null +} + +export function useRichMarkdownReviewController({ + addDiffComment, + allDiffComments, + content, + editorRef, + filePath, + markdownAnnotationFilePath, + markdownAnnotationsEnabled, + markdownReviewContent, + markdownSourceLineOffset, + rootRef, + scrollContainerRef, + worktreeId, + worktreeRoot +}: UseRichMarkdownReviewControllerOptions) { + const [annotationTarget, setAnnotationTarget] = useState( + null + ) + const [annotationPopover, setAnnotationPopover] = useState( + null + ) + const annotationPopoverRef = useRef(null) + const canAnnotateRichMarkdownRef = useRef(false) + const markdownCommentsRef = useRef([]) + const markdownSourceLineOffsetRef = useRef(markdownSourceLineOffset) + const annotationTargetFrameRef = useRef(null) + const { + canAnnotateRichMarkdown, + markdownComments, + markdownReviewNotes, + sourceRelativePath, + unsentMarkdownReviewScope + } = useRichMarkdownReviewData({ + allDiffComments, + filePath, + markdownAnnotationFilePath, + markdownAnnotationsEnabled, + markdownReviewContent, + worktreeRoot + }) + + annotationPopoverRef.current = annotationPopover + canAnnotateRichMarkdownRef.current = canAnnotateRichMarkdown + markdownCommentsRef.current = markdownComments + markdownSourceLineOffsetRef.current = markdownSourceLineOffset + + const copyFeedback = useRichMarkdownReviewCopyFeedback({ + markdownReviewContent, + markdownReviewNotes, + rootRef + }) + const { clearReviewCopyTimers } = copyFeedback + const rail = useRichMarkdownReviewRailController({ + canAnnotateRichMarkdown, + content, + editorRef, + markdownComments, + markdownSourceLineOffset, + markdownSourceLineOffsetRef, + scrollContainerRef + }) + const { cancelNotePositionFrame, clearAttentionTimers, setReviewRailOpen } = rail + const reviewRailExpanded = shouldExpandRichMarkdownReviewRail({ + hasReviewNotes: markdownComments.length > 0, + reviewRailOpen: rail.reviewRailOpen, + hasDraftNote: annotationPopover !== null + }) + + const clearAllAnnotationHighlights = useCallback((): void => { + const editor = editorRef.current + if (!editor) { + return + } + editor.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: null, + noteRanges: [] + }) + ) + }, [editorRef]) + + const clearAnnotationHighlight = useCallback((): void => { + const editor = editorRef.current + editor?.view.dispatch(editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, null)) + }, [editorRef]) + + const clearAnnotationTarget = useCallback((): void => setAnnotationTarget(null), []) + + const clearTransientReviewState = useCallback((): void => { + clearAttentionTimers() + clearReviewCopyTimers() + clearAllAnnotationHighlights() + cancelFrame(annotationTargetFrameRef) + cancelNotePositionFrame() + }, [ + cancelNotePositionFrame, + clearAllAnnotationHighlights, + clearAttentionTimers, + clearReviewCopyTimers + ]) + + const syncAnnotationTarget = useCallback( + (editor: Editor): void => { + cancelFrame(annotationTargetFrameRef) + annotationTargetFrameRef.current = window.requestAnimationFrame(() => { + annotationTargetFrameRef.current = null + const root = rootRef.current + if (!root || annotationPopoverRef.current || !canAnnotateRichMarkdownRef.current) { + setAnnotationTarget(null) + return + } + const target = getRichMarkdownAnnotationTarget(editor, root) + const hasExistingComment = + target && + hasRichMarkdownCommentForRange( + markdownCommentsRef.current, + target, + markdownSourceLineOffsetRef.current + ) + setAnnotationTarget(hasExistingComment ? null : target) + }) + }, + [rootRef] + ) + + const submitAnnotation = useCallback( + async (body: string): Promise => { + if (!annotationPopover || sourceRelativePath === null) { + return + } + const result = await addDiffComment({ + worktreeId, + filePath: sourceRelativePath, + source: 'markdown', + startLine: + annotationPopover.startLine === undefined + ? undefined + : annotationPopover.startLine + markdownSourceLineOffset, + lineNumber: annotationPopover.lineNumber + markdownSourceLineOffset, + selectedText: annotationPopover.selectedText, + body, + side: 'modified' + }) + if (!result) { + console.error('Failed to add markdown comment — draft preserved') + return + } + updateRichMarkdownAnnotationHighlightsAfterSubmit({ + annotationPopover, + comments: [...markdownComments, result], + editor: editorRef.current, + markdownSourceLineOffset + }) + setAnnotationPopover(null) + clearAnnotationHighlight() + window.getSelection()?.removeAllRanges() + }, + [ + addDiffComment, + annotationPopover, + clearAnnotationHighlight, + editorRef, + markdownComments, + markdownSourceLineOffset, + sourceRelativePath, + worktreeId + ] + ) + + const openAnnotationPopover = useCallback((): void => { + if (!annotationTarget || !canAnnotateRichMarkdown) { + return + } + const editor = editorRef.current + const root = rootRef.current + const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null + const target = editor + ? clampRichMarkdownAnnotationTarget(editor, liveTarget ?? annotationTarget) + : annotationTarget + if ( + !target || + hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset) + ) { + setAnnotationTarget(null) + return + } + editor?.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: { from: target.from, to: target.to } + }) + ) + // Why: opening a draft should reserve the notes rail immediately; saved notes stay visible. + setReviewRailOpen(true) + setAnnotationPopover(target) + setAnnotationTarget(null) + }, [ + annotationTarget, + canAnnotateRichMarkdown, + editorRef, + markdownComments, + markdownSourceLineOffset, + rootRef, + setReviewRailOpen + ]) + + useEffect(() => { + if (canAnnotateRichMarkdown) { + return + } + // Why: disabling annotations must immediately remove stale popovers and + // highlights that cannot be derived from the next non-annotatable render. + // oxlint-disable-next-line react-doctor/no-adjust-state-on-prop-change + setAnnotationTarget(null) + // oxlint-disable-next-line react-doctor/no-adjust-state-on-prop-change + setAnnotationPopover(null) + clearAllAnnotationHighlights() + }, [canAnnotateRichMarkdown, clearAllAnnotationHighlights]) + + return { + ...copyFeedback, + ...rail, + annotationPopover, + annotationTarget, + canAnnotateRichMarkdown, + clearAnnotationHighlight, + clearAnnotationTarget, + clearAllAnnotationHighlights, + clearTransientReviewState, + markdownComments, + markdownCommentsRef, + markdownSourceLineOffsetRef, + openAnnotationPopover, + reviewRailExpanded, + setAnnotationPopover, + submitAnnotation, + syncAnnotationTarget, + unsentMarkdownReviewScope + } +} + +function cancelFrame(ref: MutableRefObject): void { + if (ref.current !== null) { + window.cancelAnimationFrame(ref.current) + ref.current = null + } +} diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewCopyFeedback.ts b/src/renderer/src/components/editor/useRichMarkdownReviewCopyFeedback.ts new file mode 100644 index 00000000000..1916954e6f3 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownReviewCopyFeedback.ts @@ -0,0 +1,81 @@ +import { useCallback, useRef, useState } from 'react' +import type { MutableRefObject } from 'react' +import { copyMarkdownReviewNotesForAgent } from '@/lib/markdown-review-note-copy' +import type { MarkdownReviewNote } from '@/lib/markdown-review-notes' + +type UseRichMarkdownReviewCopyFeedbackOptions = { + markdownReviewContent: string + markdownReviewNotes: MarkdownReviewNote[] + rootRef: MutableRefObject +} + +export function useRichMarkdownReviewCopyFeedback({ + markdownReviewContent, + markdownReviewNotes, + rootRef +}: UseRichMarkdownReviewCopyFeedbackOptions) { + const [reviewNotesCopied, setReviewNotesCopied] = useState(false) + const [copiedReviewNoteId, setCopiedReviewNoteId] = useState(null) + const reviewNotesCopiedResetTimerRef = useRef(null) + const copiedReviewNoteResetTimerRef = useRef(null) + + const clearReviewCopyTimers = useCallback((): void => { + clearWindowTimer(reviewNotesCopiedResetTimerRef) + clearWindowTimer(copiedReviewNoteResetTimerRef) + }, []) + + const handleCopyMarkdownReviewNotes = useCallback(async (): Promise => { + const copied = await copyReviewNotes(markdownReviewNotes, markdownReviewContent) + if (copied && rootRef.current) { + clearReviewCopyTimers() + setCopiedReviewNoteId(null) + setReviewNotesCopied(true) + reviewNotesCopiedResetTimerRef.current = window.setTimeout(() => { + reviewNotesCopiedResetTimerRef.current = null + setReviewNotesCopied(false) + }, 1600) + } + }, [clearReviewCopyTimers, markdownReviewContent, markdownReviewNotes, rootRef]) + + const handleCopyMarkdownReviewNote = useCallback( + async (note: MarkdownReviewNote): Promise => { + const copied = await copyReviewNotes([note], markdownReviewContent) + if (copied && rootRef.current) { + clearWindowTimer(copiedReviewNoteResetTimerRef) + setCopiedReviewNoteId(note.id) + copiedReviewNoteResetTimerRef.current = window.setTimeout(() => { + copiedReviewNoteResetTimerRef.current = null + setCopiedReviewNoteId(null) + }, 1600) + } + }, + [markdownReviewContent, rootRef] + ) + + return { + clearReviewCopyTimers, + copiedReviewNoteId, + handleCopyMarkdownReviewNote, + handleCopyMarkdownReviewNotes, + reviewNotesCopied + } +} + +function clearWindowTimer(ref: MutableRefObject): void { + if (ref.current !== null) { + window.clearTimeout(ref.current) + ref.current = null + } +} + +async function copyReviewNotes(notes: MarkdownReviewNote[], content: string): Promise { + try { + return await copyMarkdownReviewNotesForAgent({ + notes, + content, + writeClipboardText: window.api.ui.writeClipboardText + }) + } catch { + return false + } +} diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewData.ts b/src/renderer/src/components/editor/useRichMarkdownReviewData.ts new file mode 100644 index 00000000000..bcca91c922e --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownReviewData.ts @@ -0,0 +1,73 @@ +import { useMemo } from 'react' +import { getRelativePathInsideRoot, normalizeRelativePath } from '@/lib/path' +import { isMarkdownComment } from '@/lib/diff-comment-compat' +import { + formatMarkdownReviewNotes, + sortMarkdownReviewNotes, + type MarkdownReviewNote +} from '@/lib/markdown-review-notes' +import type { NotesSendMenuScope } from './NotesSendMenu' +import type { DiffComment } from '../../../../shared/types' + +type UseRichMarkdownReviewDataOptions = { + allDiffComments: DiffComment[] | undefined + filePath: string + markdownAnnotationFilePath?: string + markdownAnnotationsEnabled: boolean + markdownReviewContent: string + worktreeRoot: string | null +} + +export function useRichMarkdownReviewData({ + allDiffComments, + filePath, + markdownAnnotationFilePath, + markdownAnnotationsEnabled, + markdownReviewContent, + worktreeRoot +}: UseRichMarkdownReviewDataOptions): { + canAnnotateRichMarkdown: boolean + markdownComments: DiffComment[] + markdownReviewNotes: MarkdownReviewNote[] + sourceRelativePath: string | null + unsentMarkdownReviewScope: NotesSendMenuScope[] +} { + const sourceRelativePath = useMemo( + () => + markdownAnnotationFilePath + ? normalizeRelativePath(markdownAnnotationFilePath) + : getRelativePathInsideRoot(filePath, worktreeRoot), + [filePath, markdownAnnotationFilePath, worktreeRoot] + ) + const canAnnotateRichMarkdown = Boolean(markdownAnnotationsEnabled && sourceRelativePath !== null) + const markdownComments = useMemo( + () => + (allDiffComments ?? []).filter( + (comment) => comment.filePath === sourceRelativePath && isMarkdownComment(comment) + ), + [allDiffComments, sourceRelativePath] + ) + const markdownReviewNotes = useMemo( + () => sortMarkdownReviewNotes(markdownComments as MarkdownReviewNote[]), + [markdownComments] + ) + const unsentMarkdownReviewScope = useMemo[]>(() => { + const unsentNotes = markdownReviewNotes.filter((note) => !note.sentAt) + return [ + { + id: 'all', + label: 'All unsent notes', + notes: unsentNotes, + prompt: formatMarkdownReviewNotes(unsentNotes, markdownReviewContent) + } + ] + }, [markdownReviewContent, markdownReviewNotes]) + + return { + canAnnotateRichMarkdown, + markdownComments, + markdownReviewNotes, + sourceRelativePath, + unsentMarkdownReviewScope + } +} diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewEditorEffects.ts b/src/renderer/src/components/editor/useRichMarkdownReviewEditorEffects.ts new file mode 100644 index 00000000000..b79cb031753 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownReviewEditorEffects.ts @@ -0,0 +1,56 @@ +import { useEffect, type RefObject } from 'react' +import type { Editor } from '@tiptap/react' +import { richMarkdownAnnotationHighlightPluginKey } from './rich-markdown-annotation-highlight' +import { getRichMarkdownAnnotationHighlightRanges } from './rich-markdown-review-annotations' +import type { DiffComment } from '../../../../shared/types' + +type UseRichMarkdownReviewEditorEffectsOptions = { + canAnnotateRichMarkdown: boolean + content: string + editor: Editor | null + markdownComments: DiffComment[] + markdownSourceLineOffset: number + scrollContainerRef: RefObject + syncAnnotationTarget: (editor: Editor) => void +} + +export function useRichMarkdownReviewEditorEffects({ + canAnnotateRichMarkdown, + content, + editor, + markdownComments, + markdownSourceLineOffset, + scrollContainerRef, + syncAnnotationTarget +}: UseRichMarkdownReviewEditorEffectsOptions): void { + useEffect(() => { + if (!editor || !canAnnotateRichMarkdown) { + return + } + const noteRanges = getRichMarkdownAnnotationHighlightRanges( + editor, + markdownComments, + markdownSourceLineOffset + ) + editor.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { noteRanges }) + ) + }, [canAnnotateRichMarkdown, content, editor, markdownComments, markdownSourceLineOffset]) + + useEffect(() => { + if (!editor) { + return + } + const container = scrollContainerRef.current + if (!container) { + return + } + const update = (): void => syncAnnotationTarget(editor) + container.addEventListener('scroll', update) + window.addEventListener('resize', update) + return () => { + container.removeEventListener('scroll', update) + window.removeEventListener('resize', update) + } + }, [editor, scrollContainerRef, syncAnnotationTarget]) +} diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts b/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts new file mode 100644 index 00000000000..0004f3aab87 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { MutableRefObject } from 'react' +import type { Editor } from '@tiptap/react' +import { + richMarkdownAnnotationHighlightPluginKey, + type RichMarkdownAnnotationHighlightRange +} from './rich-markdown-annotation-highlight' +import { getRichMarkdownRangeBounds } from './rich-markdown-range-bounds' +import { + clearRichMarkdownNotePositions, + getRichMarkdownAnnotationHighlightRangesForComment +} from './rich-markdown-review-annotations' +import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' +import { measureRichMarkdownReviewNotePositions } from './rich-markdown-review-note-positioning' +import type { DiffComment } from '../../../../shared/types' + +type UseRichMarkdownReviewRailControllerOptions = { + canAnnotateRichMarkdown: boolean + content: string + editorRef: MutableRefObject + markdownComments: DiffComment[] + markdownSourceLineOffset: number + markdownSourceLineOffsetRef: MutableRefObject + scrollContainerRef: MutableRefObject +} + +export function useRichMarkdownReviewRailController({ + canAnnotateRichMarkdown, + content, + editorRef, + markdownComments, + markdownSourceLineOffset, + markdownSourceLineOffsetRef, + scrollContainerRef +}: UseRichMarkdownReviewRailControllerOptions) { + const [reviewRailOpen, setReviewRailOpen] = useState(false) + const [activeReviewCommentId, setActiveReviewCommentId] = useState(null) + const [attentionReviewCommentId, setAttentionReviewCommentId] = useState(null) + const [notePositions, setNotePositions] = useState([]) + const notePositionsRef = useRef([]) + const attentionReviewCommentTimeoutRef = useRef(null) + const sourceAttentionTimeoutRef = useRef(null) + const notePositionsFrameRef = useRef(null) + const reviewRailVisible = markdownComments.length > 0 && reviewRailOpen + + notePositionsRef.current = notePositions + + const clearAttentionTimers = useCallback((): void => { + clearWindowTimer(attentionReviewCommentTimeoutRef) + clearWindowTimer(sourceAttentionTimeoutRef) + }, []) + + const cancelNotePositionFrame = useCallback((): void => { + cancelFrame(notePositionsFrameRef) + }, []) + + const syncNotePositions = useCallback((): void => { + const editor = editorRef.current + const container = scrollContainerRef.current + if ( + !reviewRailVisible || + !canAnnotateRichMarkdown || + !editor || + !container || + markdownComments.length === 0 + ) { + clearRichMarkdownNotePositions(setNotePositions) + return + } + setNotePositions( + measureRichMarkdownReviewNotePositions({ + editor, + container, + markdownComments, + markdownSourceLineOffset + }) + ) + }, [ + canAnnotateRichMarkdown, + editorRef, + markdownComments, + markdownSourceLineOffset, + reviewRailVisible, + scrollContainerRef + ]) + + const requestSyncNotePositions = useCallback((): void => { + if (!reviewRailVisible) { + clearRichMarkdownNotePositions(setNotePositions) + return + } + if (notePositionsFrameRef.current !== null) { + return + } + notePositionsFrameRef.current = window.requestAnimationFrame(() => { + notePositionsFrameRef.current = null + syncNotePositions() + }) + }, [reviewRailVisible, syncNotePositions]) + + const pulseRichMarkdownReviewNote = useCallback((commentId: string): void => { + clearWindowTimer(attentionReviewCommentTimeoutRef) + setAttentionReviewCommentId(null) + window.requestAnimationFrame(() => { + setAttentionReviewCommentId(commentId) + attentionReviewCommentTimeoutRef.current = window.setTimeout(() => { + attentionReviewCommentTimeoutRef.current = null + setAttentionReviewCommentId(null) + }, 900) + }) + }, []) + + const scrollRichMarkdownReviewNoteCardIntoView = useCallback( + (commentId: string): void => { + setReviewRailOpen(true) + setActiveReviewCommentId(commentId) + pulseRichMarkdownReviewNote(commentId) + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => + centerReviewNoteCard(scrollContainerRef.current, notePositionsRef.current, commentId) + ) + }) + }, + [pulseRichMarkdownReviewNote, scrollContainerRef] + ) + + const pulseRichMarkdownSourceRange = useCallback( + (range: RichMarkdownAnnotationHighlightRange): void => { + const editor = editorRef.current + if (!editor) { + return + } + clearWindowTimer(sourceAttentionTimeoutRef) + editor.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { activeRange: null }) + ) + window.requestAnimationFrame(() => { + const latestEditor = editorRef.current + if (!latestEditor) { + return + } + latestEditor.view.dispatch( + latestEditor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: range + }) + ) + sourceAttentionTimeoutRef.current = window.setTimeout(() => { + sourceAttentionTimeoutRef.current = null + editorRef.current?.view.dispatch( + editorRef.current.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: null + }) + ) + }, 900) + }) + }, + [editorRef] + ) + + const scrollRichMarkdownReviewNoteSourceIntoView = useCallback( + (comment: DiffComment): void => { + const editor = editorRef.current + const container = scrollContainerRef.current + if (!editor || !container) { + return + } + const ranges = getRichMarkdownAnnotationHighlightRangesForComment( + editor, + comment, + markdownSourceLineOffsetRef.current + ) + const bounds = getRichMarkdownRangeBounds(ranges) + if (!bounds) { + return + } + const maxPos = editor.state.doc.content.size + const startCoords = editor.view.coordsAtPos(Math.max(1, Math.min(bounds.from, maxPos))) + const endCoords = editor.view.coordsAtPos(Math.max(1, Math.min(bounds.to, maxPos))) + const containerRect = container.getBoundingClientRect() + const sourceTop = startCoords.top - containerRect.top + container.scrollTop + const sourceBottom = endCoords.bottom - containerRect.top + container.scrollTop + setActiveReviewCommentId(comment.id) + container.scrollTo({ + top: Math.max(0, (sourceTop + sourceBottom) / 2 - container.clientHeight / 2), + behavior: 'smooth' + }) + pulseRichMarkdownSourceRange({ from: bounds.from, to: bounds.to }) + }, + [editorRef, markdownSourceLineOffsetRef, pulseRichMarkdownSourceRange, scrollContainerRef] + ) + + useEffect(() => requestSyncNotePositions(), [content, markdownComments, requestSyncNotePositions]) + + useEffect(() => { + if (!reviewRailVisible) { + clearRichMarkdownNotePositions(setNotePositions) + return + } + const container = scrollContainerRef.current + if (!container) { + return + } + const update = (): void => requestSyncNotePositions() + container.addEventListener('scroll', update, { passive: true }) + window.addEventListener('resize', update) + requestSyncNotePositions() + return () => { + container.removeEventListener('scroll', update) + window.removeEventListener('resize', update) + } + }, [requestSyncNotePositions, reviewRailVisible, scrollContainerRef]) + + return { + activeReviewCommentId, + attentionReviewCommentId, + cancelNotePositionFrame, + clearAttentionTimers, + notePositions, + reviewRailOpen, + reviewRailVisible, + scrollRichMarkdownReviewNoteCardIntoView, + scrollRichMarkdownReviewNoteSourceIntoView, + setReviewRailOpen, + syncNotePositions + } +} + +function clearWindowTimer(ref: MutableRefObject): void { + if (ref.current !== null) { + window.clearTimeout(ref.current) + ref.current = null + } +} + +function cancelFrame(ref: MutableRefObject): void { + if (ref.current !== null) { + window.cancelAnimationFrame(ref.current) + ref.current = null + } +} + +function centerReviewNoteCard( + container: HTMLDivElement | null, + positions: RichMarkdownReviewNotePosition[], + commentId: string +): void { + const card = container?.querySelector( + `[data-rich-markdown-review-note-id="${CSS.escape(commentId)}"]` + ) + if (!container) { + return + } + const position = positions.find((item) => item.comment.id === commentId) + const cardHeight = card?.offsetHeight ?? 72 + const cardTop = position?.top ?? card?.offsetTop + if (cardTop === undefined) { + return + } + const targetTop = cardTop - Math.max(0, (container.clientHeight - cardHeight) / 2) + container.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }) +} diff --git a/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx b/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx index 51dae7d655a..d581b000fcf 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx @@ -1,6 +1,5 @@ -/* eslint-disable max-lines -- Why: orchestrator for the inline tour surface; splitting it here would scatter related state across helpers without making the file easier to read. */ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' -import type { JSX, KeyboardEvent, ReactNode } from 'react' +import type { JSX, ReactNode } from 'react' import { DEFAULT_FEATURE_WALL_WORKFLOW_ID, FEATURE_WALL_WORKFLOWS, @@ -21,10 +20,6 @@ import { useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' import { usePrefersReducedMotion } from './feature-wall-modal-helpers' -import { - getFeatureWallRailNavigationTarget, - type FeatureWallRailNavigationKey -} from './feature-wall-rail-navigation' import { toFeatureWallAssetUrl, useFeatureWallAssetBaseUrl } from './feature-wall-assets' import { useFeatureWallTaskSourcePresentation } from './use-feature-wall-task-source-presentation' import { useFeatureWallCompletion } from './use-feature-wall-completion' @@ -32,9 +27,9 @@ import { useFeatureWallTourTelemetry } from './use-feature-wall-tour-telemetry' import { FeatureWallContinueButton } from './FeatureWallContinueButton' import { FeatureWallTourPanel } from './FeatureWallTourPanel' import { getFeatureWallActiveStepCopy } from './feature-wall-active-step-copy' -import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' - -const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'Home', 'End']) +import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' +import { useFeatureWallTourKeyboardShortcut } from './use-feature-wall-tour-keyboard-shortcut' +import { useFeatureWallTourRailKeydown } from './use-feature-wall-tour-rail-keydown' type FeatureWallTourSurfaceProps = { isOpen: boolean @@ -252,23 +247,10 @@ export function FeatureWallTourSurface({ [markReviewStepVisited] ) - const handleRailKeyDown = (event: KeyboardEvent, index: number): void => { - if (!NAVIGATION_KEYS.has(event.key)) { - return - } - event.preventDefault() - const nextIndex = getFeatureWallRailNavigationTarget({ - currentIndex: index, - key: event.key as FeatureWallRailNavigationKey, - itemCount: FEATURE_WALL_WORKFLOWS.length - }) - const nextWorkflow = FEATURE_WALL_WORKFLOWS[nextIndex] - if (!nextWorkflow) { - return - } - handleSelect(nextWorkflow) - railRefs.current[nextIndex]?.focus() - } + const handleRailKeyDown = useFeatureWallTourRailKeydown({ + railRefs, + onSelectWorkflow: handleSelect + }) const isLastWorkflow = selectedIndex >= FEATURE_WALL_WORKFLOWS.length - 1 const agentsStepIndex = @@ -366,20 +348,11 @@ export function FeatureWallTourSurface({ workbenchSteps ]) - useEffect(() => { - if (!isOpen || !enableKeyboardShortcut) { - return - } - const onKeyDown = (event: globalThis.KeyboardEvent): void => { - if (!isScreenSubmitShortcut(event)) { - return - } - event.preventDefault() - handleContinue() - } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [enableKeyboardShortcut, handleContinue, isOpen]) + useFeatureWallTourKeyboardShortcut({ + isOpen, + enabled: enableKeyboardShortcut, + onContinue: handleContinue + }) if (!isOpen) { return null diff --git a/src/renderer/src/components/feature-wall/SetupScriptAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/SetupScriptAnimatedVisual.tsx index 93565cf618b..19da3614a29 100644 --- a/src/renderer/src/components/feature-wall/SetupScriptAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/SetupScriptAnimatedVisual.tsx @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- Why: this is a single storyboarded setup-script animation; splitting the phase markup from target measurement makes the visual harder to verify. */ +/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: this visual is a timed storyboard; phase and typed-name state intentionally advance from animation effects and the reduced-motion gate. */ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { JSX, ReactNode, RefObject } from 'react' import { FolderGit2, Plus, TerminalSquare } from 'lucide-react' @@ -6,6 +6,8 @@ import { cn } from '@/lib/utils' import { ClaudeIcon } from '../status-bar/icons' import { CodexInlineIcon, CursorIcon, WorkingSpinner } from './feature-tour-preview-glyphs' import { FeatureWallClickRing } from './FeatureWallClickRing' +import { SetupScriptNewWorkspaceModal } from './SetupScriptNewWorkspaceModal' +import { SetupScriptWorkspaceListCard } from './SetupScriptWorkspaceListCard' const PHASES = [ { name: 'create-init', duration: 1200 }, @@ -157,7 +159,7 @@ export function SetupScriptAnimatedVisual(props: { reducedMotion: boolean }): JS
- - {/* Create Worktree Modal Overlay */} - -
- - - {props.title} - -
- {props.prompt && ( -
- {props.state === 'working' || props.state === 'starting' || props.state === 'setup' ? ( - - ) : ( - - )} - - {props.icon} - - - {props.state === 'setup' ? 'running setup' : props.prompt} - -
- )} -
- ) -} - -function NewWorkspaceModal(props: { - visible: boolean - nameValue: string - nameTyping: boolean - createHovered: boolean - createClicked: boolean - createButtonRef: RefObject -}): JSX.Element { - return ( -
-
-
Create Worktree
-
- - - -
-
- Create worktree -
-
-
- ) -} - -function ModalField(props: { label: string; value: string; typing?: boolean }): JSX.Element { - return ( -
- {props.label} -
- {props.value} - {props.typing ? ( - - ) : null} -
-
- ) -} - function TerminalLine(props: { children: ReactNode muted?: boolean diff --git a/src/renderer/src/components/feature-wall/SetupScriptNewWorkspaceModal.tsx b/src/renderer/src/components/feature-wall/SetupScriptNewWorkspaceModal.tsx new file mode 100644 index 00000000000..935b4986f97 --- /dev/null +++ b/src/renderer/src/components/feature-wall/SetupScriptNewWorkspaceModal.tsx @@ -0,0 +1,72 @@ +import type { JSX, RefObject } from 'react' +import { cn } from '@/lib/utils' + +type SetupScriptNewWorkspaceModalProps = { + visible: boolean + nameValue: string + nameTyping: boolean + createHovered: boolean + createClicked: boolean + createButtonRef: RefObject +} + +export function SetupScriptNewWorkspaceModal({ + visible, + nameValue, + nameTyping, + createHovered, + createClicked, + createButtonRef +}: SetupScriptNewWorkspaceModalProps): JSX.Element { + return ( +
+
+
Create Worktree
+
+ + + +
+
+ Create worktree +
+
+
+ ) +} + +function SetupScriptModalField(props: { + label: string + value: string + typing?: boolean +}): JSX.Element { + return ( +
+ {props.label} +
+ {props.value} + {props.typing ? ( + + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/feature-wall/SetupScriptWorkspaceListCard.tsx b/src/renderer/src/components/feature-wall/SetupScriptWorkspaceListCard.tsx new file mode 100644 index 00000000000..450bc30bfd9 --- /dev/null +++ b/src/renderer/src/components/feature-wall/SetupScriptWorkspaceListCard.tsx @@ -0,0 +1,54 @@ +import type { JSX, ReactNode } from 'react' +import { cn } from '@/lib/utils' +import { WorkingSpinner } from './feature-tour-preview-glyphs' + +type SetupScriptWorkspaceListCardProps = { + title: string + active: boolean + prompt?: string + icon?: ReactNode + state: 'idle' | 'starting' | 'setup' | 'working' + reducedMotion: boolean + className?: string +} + +export function SetupScriptWorkspaceListCard( + props: SetupScriptWorkspaceListCardProps +): JSX.Element { + return ( +
+
+ + + {props.title} + +
+ {props.prompt ? ( +
+ {props.state === 'working' || props.state === 'starting' || props.state === 'setup' ? ( + + ) : ( + + )} + + {props.icon} + + + {props.state === 'setup' ? 'running setup' : props.prompt} + +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/feature-wall/use-feature-wall-tour-keyboard-shortcut.ts b/src/renderer/src/components/feature-wall/use-feature-wall-tour-keyboard-shortcut.ts new file mode 100644 index 00000000000..9e0f3c5e8ea --- /dev/null +++ b/src/renderer/src/components/feature-wall/use-feature-wall-tour-keyboard-shortcut.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react' +import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' + +export function useFeatureWallTourKeyboardShortcut({ + isOpen, + enabled, + onContinue +}: { + isOpen: boolean + enabled: boolean + onContinue: () => void +}): void { + useEffect(() => { + if (!isOpen || !enabled) { + return + } + const onKeyDown = (event: globalThis.KeyboardEvent): void => { + if (!isScreenSubmitShortcut(event)) { + return + } + event.preventDefault() + onContinue() + } + window.addEventListener('keydown', onKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + }, [enabled, isOpen, onContinue]) +} diff --git a/src/renderer/src/components/feature-wall/use-feature-wall-tour-rail-keydown.ts b/src/renderer/src/components/feature-wall/use-feature-wall-tour-rail-keydown.ts new file mode 100644 index 00000000000..1c0347d15ca --- /dev/null +++ b/src/renderer/src/components/feature-wall/use-feature-wall-tour-rail-keydown.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react' +import type { KeyboardEvent, RefObject } from 'react' +import { + FEATURE_WALL_WORKFLOWS, + type FeatureWallWorkflow +} from '../../../../shared/feature-wall-workflows' +import { + getFeatureWallRailNavigationTarget, + type FeatureWallRailNavigationKey +} from './feature-wall-rail-navigation' + +const FEATURE_WALL_TOUR_NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'Home', 'End']) + +export function useFeatureWallTourRailKeydown({ + railRefs, + onSelectWorkflow +}: { + railRefs: RefObject<(HTMLButtonElement | null)[]> + onSelectWorkflow: (workflow: FeatureWallWorkflow) => void +}): (event: KeyboardEvent, index: number) => void { + return useCallback( + (event: KeyboardEvent, index: number): void => { + if (!FEATURE_WALL_TOUR_NAVIGATION_KEYS.has(event.key)) { + return + } + event.preventDefault() + const nextIndex = getFeatureWallRailNavigationTarget({ + currentIndex: index, + key: event.key as FeatureWallRailNavigationKey, + itemCount: FEATURE_WALL_WORKFLOWS.length + }) + const nextWorkflow = FEATURE_WALL_WORKFLOWS[nextIndex] + if (!nextWorkflow) { + return + } + onSelectWorkflow(nextWorkflow) + railRefs.current[nextIndex]?.focus() + }, + [onSelectWorkflow, railRefs] + ) +} diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index bc06801c170..5bf08659f45 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -1,11 +1,6 @@ -/* eslint-disable max-lines -- Why: the mobile page keeps pairing, device - revoke, QR, and stage transitions together so the flow remains auditable. */ import { useCallback, useEffect, useRef, useState } from 'react' import QRCodeBrowser from 'qrcode/lib/browser' import { toast } from 'sonner' -import { Eye, EyeOff, X } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { PhoneCarousel } from './PhoneCarousel' @@ -17,6 +12,7 @@ import { type Platform, type StepIndex } from './MobileHero' +import { PLATFORM_COPY } from './mobile-platform-copy' import { selectRefreshedNetworkAddress, type MobileNetworkInterface @@ -26,6 +22,7 @@ import { shouldShowPairedAfterDeviceRefresh, type MobilePageStage as FlowStage } from './mobile-page-stage' +import { MobilePageToolbar } from './MobilePageToolbar' async function renderQrDataUrl(text: string): Promise { return QRCodeBrowser.toDataURL(text, { @@ -35,22 +32,6 @@ async function renderQrDataUrl(text: string): Promise { }) } -export const PLATFORM_COPY: Record< - Platform, - { description: string; ctaLabel: string; url: string } -> = { - ios: { - description: 'Scan with your iPhone camera to open the App Store.', - ctaLabel: 'Open App Store', - url: 'https://apps.apple.com/app/orca-ide/id6766130217' - }, - android: { - description: 'Scan with your Android camera to download the latest APK from GitHub Releases.', - ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-v0.0.11/app-release.apk' - } -} - export default function MobilePage(): React.JSX.Element { // Why: stage starts unresolved so we don't flash the intro before we know // whether any devices are already paired. @@ -428,33 +409,11 @@ export default function MobilePage(): React.JSX.Element { return (
-
- - - - - - Close · Esc - - - -
+
{stage === null ? null : stage === 'intro' ? ( diff --git a/src/renderer/src/components/mobile/MobilePageToolbar.tsx b/src/renderer/src/components/mobile/MobilePageToolbar.tsx new file mode 100644 index 00000000000..9d034dc373a --- /dev/null +++ b/src/renderer/src/components/mobile/MobilePageToolbar.tsx @@ -0,0 +1,45 @@ +import { Eye, EyeOff, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +type MobilePageToolbarProps = { + showMobileButton: boolean + onClose: () => void + onToggleMobileSidebarButton: () => void +} + +export function MobilePageToolbar({ + showMobileButton, + onClose, + onToggleMobileSidebarButton +}: MobilePageToolbarProps): React.JSX.Element { + return ( +
+ + + + + + Close · Esc + + + +
+ ) +} diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts new file mode 100644 index 00000000000..73cb81851de --- /dev/null +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -0,0 +1,17 @@ +import type { Platform } from './MobileHero' + +export const PLATFORM_COPY: Record< + Platform, + { description: string; ctaLabel: string; url: string } +> = { + ios: { + description: 'Scan with your iPhone camera to open the App Store.', + ctaLabel: 'Open App Store', + url: 'https://apps.apple.com/app/orca-ide/id6766130217' + }, + android: { + description: 'Scan with your Android camera to download the latest APK from GitHub Releases.', + ctaLabel: 'Download APK', + url: 'https://github.com/stablyai/orca/releases/download/mobile-v0.0.11/app-release.apk' + } +} diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx index 1be5d54678f..8f55ea9546f 100644 --- a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx +++ b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx @@ -1,6 +1,5 @@ -/* eslint-disable max-lines -- Why: create-review dialog keeps provider copy, base/head selection, template controls, and submit state together so GitHub PR and GitLab MR creation stay symmetric. */ import React, { useCallback, useRef, useState } from 'react' -import { Check, ChevronsUpDown, Loader2, Sparkles, Square, RefreshCw } from 'lucide-react' +import { Check, ChevronsUpDown, Loader2 } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { @@ -13,7 +12,6 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import type { @@ -30,6 +28,7 @@ import { } from '../../../../shared/source-control-ai' import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key' import { getRuntimeGitScope } from '@/runtime/runtime-git-client' +import { CreatePullRequestGenerateButton } from './CreatePullRequestGenerateButton' type CreatePullRequestDialogProps = { open: boolean @@ -283,42 +282,15 @@ export function CreatePullRequestDialog({
Create {copy.titleLabel} {aiGenerationEnabled ? ( -
- {generating ? ( - - - - - - Generating {copy.shortLabel} details. Click to stop. - - - ) : ( - - )} -
+ void handleGenerate()} + onCancelGenerate={handleCancelGenerate} + /> ) : null}
diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestGenerateButton.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestGenerateButton.tsx new file mode 100644 index 00000000000..72beb345451 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/CreatePullRequestGenerateButton.tsx @@ -0,0 +1,64 @@ +import { RefreshCw, Sparkles, Square } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +export function CreatePullRequestGenerateButton({ + generating, + generateDisabled, + generateDisabledReason, + shortLabel, + reviewLabel, + onGenerate, + onCancelGenerate +}: { + generating: boolean + generateDisabled: boolean + generateDisabledReason: string | null | undefined + shortLabel: 'PR' | 'MR' + reviewLabel: 'pull request' | 'merge request' + onGenerate: () => void + onCancelGenerate: () => void +}): React.JSX.Element { + if (generating) { + return ( +
+ + + + + + Generating {shortLabel} details. Click to stop. + + +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx index 96cd7905944..26bfe60f936 100644 --- a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx +++ b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: Checks owns one compact hosted-review action bar; keeping GitHub PR and GitLab MR action branching together keeps provider parity visible. */ import React, { useCallback, useMemo, useState } from 'react' import { LoaderCircle, @@ -27,6 +26,7 @@ import type { PRInfo, Repo, Worktree } from '../../../../shared/types' import type { GitHubPRMergeMethod } from '../../../../shared/types' import { resolveGitHubPRMergeMethods } from '../../../../shared/github-pr-merge-methods' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' +import { presentGitLabMRMergeState } from './gitlab-mr-merge-state' type HostedReviewActionInfo = Pick< HostedReviewInfo, @@ -39,63 +39,6 @@ type HostedReviewActionInfo = Pick< > > -function presentGitLabMRMergeState(review: HostedReviewActionInfo): { - label: string - tooltip: string - directMergeAvailable: boolean -} { - if (review.state === 'merged') { - return { - label: 'Merged', - tooltip: 'This merge request is already merged', - directMergeAvailable: false - } - } - if (review.state === 'closed') { - return { - label: 'Closed', - tooltip: 'This merge request is closed', - directMergeAvailable: false - } - } - if (review.state === 'draft') { - return { - label: 'Draft', - tooltip: 'This merge request is still a draft', - directMergeAvailable: false - } - } - if (review.mergeable === 'CONFLICTING') { - return { - label: 'Conflicts', - tooltip: 'GitLab reports merge conflicts', - directMergeAvailable: false - } - } - if (review.status === 'failure') { - return { - label: 'Checks failed', - tooltip: 'GitLab says this MR can merge, but some pipeline jobs failed', - directMergeAvailable: true - } - } - if (review.status === 'pending') { - return { - label: 'Checks pending', - tooltip: 'GitLab says this MR can merge, but the pipeline is still running', - directMergeAvailable: true - } - } - return { - label: 'Able to merge', - tooltip: - review.mergeable === 'UNKNOWN' - ? 'GitLab has not reported a final merge status' - : 'GitLab says this MR can merge', - directMergeAvailable: true - } -} - export default function HostedReviewActions({ review, githubPR, diff --git a/src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.ts b/src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.ts new file mode 100644 index 00000000000..0ca4ddc547e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.ts @@ -0,0 +1,60 @@ +import type { HostedReviewInfo } from '../../../../shared/hosted-review' + +type GitLabMRMergeStateReview = Pick + +export function presentGitLabMRMergeState(review: GitLabMRMergeStateReview): { + label: string + tooltip: string + directMergeAvailable: boolean +} { + if (review.state === 'merged') { + return { + label: 'Merged', + tooltip: 'This merge request is already merged', + directMergeAvailable: false + } + } + if (review.state === 'closed') { + return { + label: 'Closed', + tooltip: 'This merge request is closed', + directMergeAvailable: false + } + } + if (review.state === 'draft') { + return { + label: 'Draft', + tooltip: 'This merge request is still a draft', + directMergeAvailable: false + } + } + if (review.mergeable === 'CONFLICTING') { + return { + label: 'Conflicts', + tooltip: 'GitLab reports merge conflicts', + directMergeAvailable: false + } + } + if (review.status === 'failure') { + return { + label: 'Checks failed', + tooltip: 'GitLab says this MR can merge, but some pipeline jobs failed', + directMergeAvailable: true + } + } + if (review.status === 'pending') { + return { + label: 'Checks pending', + tooltip: 'GitLab says this MR can merge, but the pipeline is still running', + directMergeAvailable: true + } + } + return { + label: 'Able to merge', + tooltip: + review.mergeable === 'UNKNOWN' + ? 'GitLab has not reported a final merge status' + : 'GitLab says this MR can merge', + directMergeAvailable: true + } +} diff --git a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx index 3e0864c5059..7d3dff9083d 100644 --- a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx +++ b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: the setting owns one collapsed form with - queued writes, model selection, and prompt draft state. Splitting the - tiny subcontrols would make the settings write flow harder to audit. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/types' @@ -9,7 +6,6 @@ import type { SourceControlAiSettingsPatch, SourceControlAiSettings } from '../../../../shared/source-control-ai-types' -import { buildBranchNamePrompt } from '../../../../shared/branch-name-from-work' import { clearSourceControlAiModelChoiceForHost, normalizeSourceControlAiSettings, @@ -35,9 +31,9 @@ import { useActiveWorktree } from '../../store/selectors' import { Button } from '../ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' import { Label } from '../ui/label' -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { AUTO_RENAME_BRANCH_ADVANCED_SEARCH_ENTRIES } from './auto-rename-branch-search' +import { AutoRenameBranchPromptEditor } from './AutoRenameBranchPromptEditor' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' @@ -52,10 +48,6 @@ type AutoRenameBranchFromWorkSettingProps = { } const INHERIT_BRANCH_MODEL_VALUE = '__inherit_branch_model__' -const BUILT_IN_BRANCH_NAME_PROMPT = buildBranchNamePrompt({ - firstPrompt: '{first agent prompt}', - assistantMessage: '{agent initial response, when available}' -}) export function shouldOpenAutoRenameBranchAdvanced(searchQuery: string): boolean { return ( normalizeSettingsSearchQuery(searchQuery) !== '' && @@ -351,73 +343,14 @@ export function AutoRenameBranchFromWorkSetting({
-
-
- -

- Appended to Orca's{' '} - - - - - -

-
-                          {BUILT_IN_BRANCH_NAME_PROMPT}
-                        
-
- - - . Orca generates only the final segment, like{' '} - fix-login-flow; your branch prefix setting - still applies. -

-
-