mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
Clean up long renderer JSX files
This commit is contained in:
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="rounded-md border border-border/70 bg-background px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<div className="text-[11px] font-medium uppercase text-muted-foreground">
|
||||
{template.category}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium">{template.label}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{template.description}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutomationEditorDialog({
|
||||
open,
|
||||
isEditing,
|
||||
@@ -144,78 +119,25 @@ export function AutomationEditorDialog({
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="border-b border-border/50 px-5 py-4 pr-12">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
{isEditing
|
||||
? 'Edit automation'
|
||||
: isEditingExternal
|
||||
? 'Edit Hermes automation'
|
||||
: isHermesCreate
|
||||
? 'Create Hermes automation'
|
||||
: 'Create automation'}
|
||||
</DialogTitle>
|
||||
<Input
|
||||
value={draft.name}
|
||||
placeholder="Weekday repo audit"
|
||||
aria-label="Automation name"
|
||||
className="h-10 max-w-md border-input bg-input/30 px-3 text-lg font-semibold text-foreground shadow-xs placeholder:text-muted-foreground dark:bg-input/30"
|
||||
onChange={(event) =>
|
||||
onDraftChange((current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{isCreateMode ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={createTarget}
|
||||
onValueChange={(value) =>
|
||||
value && onCreateTargetChange(value as AutomationCreateTarget)
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="grid grid-cols-2"
|
||||
>
|
||||
<ToggleGroupItem value="orca" className={MODE_TOGGLE_ITEM_CLASS}>
|
||||
Orca
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="hermes" className={MODE_TOGGLE_ITEM_CLASS}>
|
||||
Hermes
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Popover open={templateOpen} onOpenChange={setTemplateOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={PICKER_TRIGGER_CLASS}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
Use template
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 p-3">
|
||||
<div className="grid gap-2">
|
||||
{AUTOMATION_TEMPLATES.map((template) => (
|
||||
<AutomationTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onSelect={() => {
|
||||
onApplyTemplate(template)
|
||||
setTemplateOpen(false)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<AutomationEditorDialogHeader
|
||||
isEditing={isEditing}
|
||||
isEditingExternal={isEditingExternal}
|
||||
isHermesCreate={isHermesCreate}
|
||||
isCreateMode={isCreateMode}
|
||||
createTarget={createTarget}
|
||||
draftName={draft.name}
|
||||
templateOpen={templateOpen}
|
||||
templates={AUTOMATION_TEMPLATES}
|
||||
modeToggleItemClassName={MODE_TOGGLE_ITEM_CLASS}
|
||||
pickerTriggerClassName={PICKER_TRIGGER_CLASS}
|
||||
onCreateTargetChange={onCreateTargetChange}
|
||||
onDraftNameChange={(name) => onDraftChange((current) => ({ ...current, name }))}
|
||||
onTemplateOpenChange={setTemplateOpen}
|
||||
onApplyTemplate={(template) => {
|
||||
onApplyTemplate(template)
|
||||
setTemplateOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-5 py-4 scrollbar-sleek">
|
||||
{draft.scheduleWarning ? (
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="rounded-md border border-border/70 bg-background px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<div className="text-[11px] font-medium uppercase text-muted-foreground">
|
||||
{template.category}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium">{template.label}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{template.description}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutomationEditorDialogHeader({
|
||||
isEditing,
|
||||
isEditingExternal,
|
||||
isHermesCreate,
|
||||
isCreateMode,
|
||||
createTarget,
|
||||
draftName,
|
||||
templateOpen,
|
||||
templates,
|
||||
modeToggleItemClassName,
|
||||
pickerTriggerClassName,
|
||||
onCreateTargetChange,
|
||||
onDraftNameChange,
|
||||
onTemplateOpenChange,
|
||||
onApplyTemplate
|
||||
}: AutomationEditorDialogHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<DialogHeader className="border-b border-border/50 px-5 py-4 pr-12">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
{isEditing
|
||||
? 'Edit automation'
|
||||
: isEditingExternal
|
||||
? 'Edit Hermes automation'
|
||||
: isHermesCreate
|
||||
? 'Create Hermes automation'
|
||||
: 'Create automation'}
|
||||
</DialogTitle>
|
||||
<Input
|
||||
value={draftName}
|
||||
placeholder="Weekday repo audit"
|
||||
aria-label="Automation name"
|
||||
className="h-10 max-w-md border-input bg-input/30 px-3 text-lg font-semibold text-foreground shadow-xs placeholder:text-muted-foreground dark:bg-input/30"
|
||||
onChange={(event) => onDraftNameChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isCreateMode ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={createTarget}
|
||||
onValueChange={(value) =>
|
||||
value && onCreateTargetChange(value as AutomationCreateTarget)
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="grid grid-cols-2"
|
||||
>
|
||||
<ToggleGroupItem value="orca" className={modeToggleItemClassName}>
|
||||
Orca
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="hermes" className={modeToggleItemClassName}>
|
||||
Hermes
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Popover open={templateOpen} onOpenChange={onTemplateOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={pickerTriggerClassName}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
Use template
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 p-3">
|
||||
<div className="grid gap-2">
|
||||
{templates.map((template) => (
|
||||
<AutomationTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onSelect={() => onApplyTemplate(template)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<div
|
||||
data-agent-row-tool-slot=""
|
||||
className="mt-0.5 min-w-0 pl-5 text-[10px] leading-snug text-muted-foreground/70"
|
||||
>
|
||||
{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. */}
|
||||
<div
|
||||
data-agent-row-tool-header="true"
|
||||
className={cn(
|
||||
'flex h-[1lh] min-w-0 items-center gap-1',
|
||||
!expanded && 'overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<Wrench className="size-2.5 shrink-0" />
|
||||
<code className="shrink-0 font-mono text-[10px]">{toolName}</code>
|
||||
{!expanded && toolInput && (
|
||||
<span className="min-w-0 truncate text-muted-foreground/60" title={toolInput}>
|
||||
{toolInput}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows,margin-top] duration-200 ease-out',
|
||||
expanded ? 'mt-0.5 grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||
)}
|
||||
>
|
||||
<pre className="min-h-0 overflow-hidden whitespace-pre-wrap break-words font-mono text-[10px] text-muted-foreground/60">
|
||||
{toolInput}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span data-agent-row-tool-placeholder="true" aria-hidden className="block h-[1lh]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 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 ? (
|
||||
<div className="mt-0.5 flex min-w-0 items-start gap-1.5 pl-5">
|
||||
{isInterrupted && (
|
||||
<span
|
||||
className="shrink-0 text-[10px] leading-snug text-muted-foreground/80"
|
||||
aria-label="Interrupted by user"
|
||||
>
|
||||
interrupted
|
||||
</span>
|
||||
)}
|
||||
{lastAssistantMessage && (
|
||||
<CommentMarkdown
|
||||
content={lastAssistantMessage}
|
||||
// Why: animate between a 1-line clipped height and the content's
|
||||
// natural height using Chromium's `interpolate-size: allow-keywords`
|
||||
// so the message body expands/collapses smoothly instead of
|
||||
// snapping. Height transition + overflow-hidden keeps the inline-
|
||||
// flattened preview clipped during the interpolation. Render the
|
||||
// markdown in both states; in the collapsed view we force every
|
||||
// nested element inline so `truncate` can ellipsize the whole
|
||||
// thing on one line. The [&_*]:inline descendant selector flattens
|
||||
// the markdown tree (lists, pre, headings, blockquotes) into inline
|
||||
// flow; block margins and list markers are suppressed by
|
||||
// [&_*]:!m-0 / [&_ul]:list-none so the preview reads as a single
|
||||
// clean line.
|
||||
className={cn(
|
||||
'min-w-0 flex-1 overflow-hidden text-[10px] leading-snug text-muted-foreground/80',
|
||||
'transition-[height] duration-200 ease-out [interpolate-size:allow-keywords]',
|
||||
expanded ? 'h-auto' : 'h-[1lh]',
|
||||
// Why: in collapsed mode we need a single truncated line. Markdown
|
||||
// blocks (pre, lists, headings) are flattened inline and forced
|
||||
// to inherit `white-space: nowrap` so <pre>/<code>'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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!expanded && (
|
||||
<div className="mt-0.5 pl-5 text-[10px] leading-snug text-muted-foreground/70"> </div>
|
||||
)
|
||||
)}
|
||||
<DashboardAgentRowToolStep
|
||||
expanded={expanded}
|
||||
isWorking={isWorking}
|
||||
toolName={toolName}
|
||||
toolInput={toolInput}
|
||||
/>
|
||||
<DashboardAgentRowMessage
|
||||
expanded={expanded}
|
||||
isInterrupted={isInterrupted}
|
||||
lastAssistantMessage={lastAssistantMessage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 : (
|
||||
<div className="mt-0.5 pl-5 text-[10px] leading-snug text-muted-foreground/70"> </div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-0.5 flex min-w-0 items-start gap-1.5 pl-5">
|
||||
{isInterrupted ? (
|
||||
<span
|
||||
className="shrink-0 text-[10px] leading-snug text-muted-foreground/80"
|
||||
aria-label="Interrupted by user"
|
||||
>
|
||||
interrupted
|
||||
</span>
|
||||
) : null}
|
||||
{lastAssistantMessage ? (
|
||||
<CommentMarkdown
|
||||
content={lastAssistantMessage}
|
||||
// Why: animate between a clipped preview and natural height without
|
||||
// measuring markdown content in JS.
|
||||
className={cn(
|
||||
'min-w-0 flex-1 overflow-hidden text-[10px] leading-snug text-muted-foreground/80',
|
||||
'transition-[height] duration-200 ease-out [interpolate-size:allow-keywords]',
|
||||
expanded ? 'h-auto' : 'h-[1lh]',
|
||||
!expanded &&
|
||||
'truncate whitespace-nowrap [&_*]:inline [&_*]:!whitespace-nowrap [&_*]:!m-0 [&_*]:!p-0 [&_ul]:list-none [&_ol]:list-none [&_br]:hidden'
|
||||
)}
|
||||
title={!expanded ? lastAssistantMessage : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
data-agent-row-tool-slot=""
|
||||
className="mt-0.5 min-w-0 pl-5 text-[10px] leading-snug text-muted-foreground/70"
|
||||
>
|
||||
{toolName ? (
|
||||
<>
|
||||
<div
|
||||
data-agent-row-tool-header="true"
|
||||
className={cn(
|
||||
'flex h-[1lh] min-w-0 items-center gap-1',
|
||||
!expanded && 'overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<Wrench className="size-2.5 shrink-0" />
|
||||
<code className="shrink-0 font-mono text-[10px]">{toolName}</code>
|
||||
{!expanded && toolInput ? (
|
||||
<span className="min-w-0 truncate text-muted-foreground/60" title={toolInput}>
|
||||
{toolInput}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{toolInput ? (
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows,margin-top] duration-200 ease-out',
|
||||
expanded ? 'mt-0.5 grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||
)}
|
||||
>
|
||||
<pre className="min-h-0 overflow-hidden whitespace-pre-wrap break-words font-mono text-[10px] text-muted-foreground/60">
|
||||
{toolInput}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span data-agent-row-tool-placeholder="true" aria-hidden className="block h-[1lh]" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<GitStagingArea, string> = {
|
||||
unstaged: 'Changes',
|
||||
@@ -325,106 +296,3 @@ export function CombinedDiffFileTree({
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function CombinedDiffFileTreeRow({
|
||||
node,
|
||||
mode,
|
||||
worktreePath,
|
||||
activeSectionKey,
|
||||
sectionIndexByKey,
|
||||
isCollapsed,
|
||||
onToggleDirectory,
|
||||
onNavigate
|
||||
}: {
|
||||
node: CombinedDiffTreeNode
|
||||
mode: CombinedDiffFileTreeMode
|
||||
worktreePath: string
|
||||
activeSectionKey: string | null
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
isCollapsed: boolean
|
||||
onToggleDirectory: (key: string) => void
|
||||
onNavigate: (entry: CombinedDiffFileTreeEntry) => void
|
||||
}): React.JSX.Element {
|
||||
if (node.type === 'directory') {
|
||||
return (
|
||||
<div
|
||||
className="group relative flex w-full items-center gap-1 py-1 pr-3 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
|
||||
style={{
|
||||
paddingLeft: `${node.depth * COMBINED_DIFF_TREE_INDENT_PX + COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX}px`
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path))
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left"
|
||||
onClick={() => onToggleDirectory(node.key)}
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('size-3 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
|
||||
/>
|
||||
{isCollapsed ? (
|
||||
<Folder className="size-3 shrink-0" />
|
||||
) : (
|
||||
<FolderOpen className="size-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{node.name}</span>
|
||||
</button>
|
||||
<span className="w-4 shrink-0 text-center text-[10px] font-bold tabular-nums text-muted-foreground/80">
|
||||
{node.fileCount}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'group flex w-full min-w-0 cursor-pointer items-center gap-1 py-1 pr-3 text-left text-xs transition-colors hover:bg-accent/40 disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent',
|
||||
activeSectionKey === sectionKey && 'bg-accent/60'
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: `${node.depth * COMBINED_DIFF_TREE_INDENT_PX + COMBINED_DIFF_TREE_FILE_PADDING_PX}px`
|
||||
}}
|
||||
disabled={disabled}
|
||||
draggable={!disabled}
|
||||
onDragStart={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
event.dataTransfer.setData(
|
||||
WORKSPACE_FILE_PATH_MIME,
|
||||
joinPath(worktreePath, node.entry.path)
|
||||
)
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
onClick={() => onNavigate(node.entry)}
|
||||
>
|
||||
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[status] }} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span className="text-foreground">{fileName}</span>
|
||||
{dirPath && <span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>}
|
||||
</span>
|
||||
<span
|
||||
className="w-4 shrink-0 text-center text-[10px] font-bold"
|
||||
style={{ color: STATUS_COLORS[status] }}
|
||||
>
|
||||
{STATUS_LABELS[status]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement | null>
|
||||
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<void>
|
||||
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 (
|
||||
<div
|
||||
ref={sectionBodyRef}
|
||||
className={cn('relative', useIntrinsicImageHeight && 'overflow-visible')}
|
||||
style={sectionBodyHeight === undefined ? undefined : { height: sectionBodyHeight }}
|
||||
>
|
||||
{popover ? (
|
||||
// Why: key by lineNumber so the popover remounts when the anchor
|
||||
// line changes instead of leaking draft state across lines.
|
||||
<DiffCommentPopover
|
||||
key={popover.lineNumber}
|
||||
lineNumber={popover.lineNumber}
|
||||
startLine={popover.startLine}
|
||||
top={popover.top}
|
||||
left={popover.left}
|
||||
placeholder={addLineCommentPlaceholder}
|
||||
submitLabel={addLineCommentLabel}
|
||||
submittingLabel="Posting…"
|
||||
onCancel={onCancelComment}
|
||||
onSubmit={onSubmitComment}
|
||||
/>
|
||||
) : null}
|
||||
{section.loading ? (
|
||||
<div className="flex h-full items-center gap-2 bg-muted/10 px-3 text-[11px] text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
) : section.error ? (
|
||||
<div className="flex h-full items-center justify-between gap-3 bg-muted/10 px-3 text-[11px] text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AlertCircle className="size-3.5 shrink-0 text-destructive" />
|
||||
<span className="truncate">{section.error}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 shrink-0 px-2 text-[11px]"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onRetrySection(index)
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : section.diffResult?.kind === 'binary' ? (
|
||||
section.diffResult.isImage ? (
|
||||
<ImageDiffViewer
|
||||
originalContent={section.diffResult.originalContent}
|
||||
modifiedContent={section.diffResult.modifiedContent}
|
||||
filePath={section.path}
|
||||
mimeType={section.diffResult.mimeType}
|
||||
sideBySide={sideBySide}
|
||||
layout={useIntrinsicImageHeight ? 'intrinsic' : 'fill'}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">Binary file changed</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isBranchMode
|
||||
? 'Text diff is unavailable for this file in branch compare.'
|
||||
: 'Text diff is unavailable for this file.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
original={section.originalContent}
|
||||
modified={section.modifiedContent}
|
||||
theme={isDark ? 'vs-dark' : 'vs'}
|
||||
onMount={onMount}
|
||||
// Why: @monaco-editor/react can dispose models before widget teardown.
|
||||
// Keep them through unmount and dispose unattached models next tick.
|
||||
originalModelPath={`${modelPathBase}:original`}
|
||||
modifiedModelPath={`${modelPathBase}:modified`}
|
||||
keepCurrentOriginalModel
|
||||
keepCurrentModifiedModel
|
||||
options={{
|
||||
readOnly: !isEditable,
|
||||
originalEditable: false,
|
||||
renderSideBySide: sideBySide,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: diffEditorFontSize,
|
||||
fontFamily: terminalFontFamily || 'monospace',
|
||||
lineNumbers: 'on',
|
||||
automaticLayout: true,
|
||||
renderOverviewRuler: false,
|
||||
scrollbar: combinedDiffSectionScrollbarOptions,
|
||||
hideUnchangedRegions: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'never'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
<div
|
||||
ref={sectionBodyRef}
|
||||
className={cn('relative', useIntrinsicImageHeight && 'overflow-visible')}
|
||||
style={sectionBodyHeight === undefined ? undefined : { height: sectionBodyHeight }}
|
||||
>
|
||||
{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.
|
||||
<DiffCommentPopover
|
||||
key={popover.lineNumber}
|
||||
lineNumber={popover.lineNumber}
|
||||
startLine={popover.startLine}
|
||||
top={popover.top}
|
||||
left={popover.left}
|
||||
placeholder={addLineCommentPlaceholder}
|
||||
submitLabel={addLineCommentLabel}
|
||||
submittingLabel="Posting…"
|
||||
onCancel={() => setPopover(null)}
|
||||
onSubmit={handleSubmitComment}
|
||||
/>
|
||||
)}
|
||||
{section.loading ? (
|
||||
<div className="flex h-full items-center gap-2 bg-muted/10 px-3 text-[11px] text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
) : section.error ? (
|
||||
<div className="flex h-full items-center justify-between gap-3 bg-muted/10 px-3 text-[11px] text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AlertCircle className="size-3.5 shrink-0 text-destructive" />
|
||||
<span className="truncate">{section.error}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 shrink-0 px-2 text-[11px]"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
retrySection(index)
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : section.diffResult?.kind === 'binary' ? (
|
||||
section.diffResult.isImage ? (
|
||||
<ImageDiffViewer
|
||||
originalContent={section.diffResult.originalContent}
|
||||
modifiedContent={section.diffResult.modifiedContent}
|
||||
filePath={section.path}
|
||||
mimeType={section.diffResult.mimeType}
|
||||
sideBySide={sideBySide}
|
||||
layout={useIntrinsicImageHeight ? 'intrinsic' : 'fill'}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">Binary file changed</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isBranchMode
|
||||
? 'Text diff is unavailable for this file in branch compare.'
|
||||
: 'Text diff is unavailable for this file.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
original={section.originalContent}
|
||||
modified={section.modifiedContent}
|
||||
theme={isDark ? 'vs-dark' : 'vs'}
|
||||
onMount={handleMount}
|
||||
// Why: @monaco-editor/react can dispose models before widget teardown.
|
||||
// Keep them through unmount and dispose unattached models next tick.
|
||||
originalModelPath={`${modelPathBase}:original`}
|
||||
modifiedModelPath={`${modelPathBase}:modified`}
|
||||
keepCurrentOriginalModel
|
||||
keepCurrentModifiedModel
|
||||
options={{
|
||||
readOnly: !isEditable,
|
||||
originalEditable: false,
|
||||
renderSideBySide: sideBySide,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: diffEditorFontSize,
|
||||
fontFamily: settings?.terminalFontFamily || 'monospace',
|
||||
lineNumbers: 'on',
|
||||
automaticLayout: true,
|
||||
renderOverviewRuler: false,
|
||||
scrollbar: combinedDiffSectionScrollbarOptions,
|
||||
hideUnchangedRegions: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'never'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DiffSectionBody
|
||||
section={section}
|
||||
index={index}
|
||||
sectionBodyRef={sectionBodyRef}
|
||||
sectionBodyHeight={sectionBodyHeight}
|
||||
useIntrinsicImageHeight={useIntrinsicImageHeight}
|
||||
popover={popover}
|
||||
addLineCommentPlaceholder={addLineCommentPlaceholder}
|
||||
addLineCommentLabel={addLineCommentLabel}
|
||||
isBranchMode={isBranchMode}
|
||||
sideBySide={sideBySide}
|
||||
isDark={isDark}
|
||||
language={language}
|
||||
modelPathBase={modelPathBase}
|
||||
isEditable={isEditable}
|
||||
diffEditorFontSize={diffEditorFontSize}
|
||||
terminalFontFamily={settings?.terminalFontFamily}
|
||||
onCancelComment={() => setPopover(null)}
|
||||
onSubmitComment={handleSubmitComment}
|
||||
onRetrySection={retrySection}
|
||||
onMount={handleMount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
export function RichMarkdownAnnotationOverlay({
|
||||
target,
|
||||
popover,
|
||||
markdownSourceLineOffset,
|
||||
onOpenPopover,
|
||||
onCancelPopover,
|
||||
onSubmit
|
||||
}: RichMarkdownAnnotationOverlayProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{target ? (
|
||||
<button
|
||||
type="button"
|
||||
className="orca-diff-comment-add-btn rich-markdown-comment-add-btn"
|
||||
style={{
|
||||
top: target.buttonTop ?? 56,
|
||||
left: target.buttonLeft ?? 16
|
||||
}}
|
||||
title="Add review note"
|
||||
aria-label="Add review note"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onOpenPopover()
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
{popover ? (
|
||||
<DiffCommentPopover
|
||||
key={`${popover.startLine ?? popover.lineNumber}:${popover.lineNumber}`}
|
||||
lineNumber={popover.lineNumber + markdownSourceLineOffset}
|
||||
startLine={
|
||||
popover.startLine === undefined
|
||||
? undefined
|
||||
: popover.startLine + markdownSourceLineOffset
|
||||
}
|
||||
top={popover.top}
|
||||
left={popover.left}
|
||||
title="Selected text"
|
||||
onCancel={onCancelPopover}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<HTMLDivElement>,
|
||||
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<HTMLDivElement | null>
|
||||
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<MarkdownReviewNote>[]
|
||||
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<HTMLInputElement | null>
|
||||
}
|
||||
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<void>
|
||||
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<boolean>
|
||||
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 (
|
||||
<div className="rich-markdown-editor-layout">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`rich-markdown-editor-shell ${
|
||||
reviewRailExpanded ? 'has-rich-markdown-review-notes' : ''
|
||||
}`.trim()}
|
||||
style={{ '--editor-font-zoom-level': editorFontZoomLevel } as React.CSSProperties}
|
||||
>
|
||||
<RichMarkdownToolbar
|
||||
editor={editor}
|
||||
onToggleLink={onToggleLink}
|
||||
onImagePick={onImagePick}
|
||||
/>
|
||||
{headerSlot}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="relative h-full overflow-auto scrollbar-editor"
|
||||
onMouseDown={(event) => {
|
||||
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')
|
||||
}}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
{reviewRailVisible && notePositions.length > 0 ? (
|
||||
<RichMarkdownReviewNoteLayer
|
||||
positions={notePositions}
|
||||
activeCommentId={activeReviewCommentId}
|
||||
attentionCommentId={attentionReviewCommentId}
|
||||
copiedCommentId={copiedReviewNoteId}
|
||||
markdownReviewContent={markdownReviewContent}
|
||||
worktreeId={worktreeId}
|
||||
filePath={filePath}
|
||||
onCopyNote={onCopyReviewNote}
|
||||
onScrollSourceIntoView={onReviewNoteSourceClick}
|
||||
onDeleteComment={onDeleteReviewComment}
|
||||
onSubmitEdit={onSubmitReviewCommentEdit}
|
||||
onContentResize={onReviewNoteContentResize}
|
||||
onDelivered={onReviewNotesDelivered}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<RichMarkdownSearchBar
|
||||
activeMatchIndex={searchState.activeMatchIndex}
|
||||
isOpen={searchState.isSearchOpen}
|
||||
matchCount={searchState.matchCount}
|
||||
onClose={searchActions.closeSearch}
|
||||
onMoveToMatch={searchActions.moveToMatch}
|
||||
onQueryChange={searchActions.setSearchQuery}
|
||||
query={searchState.searchQuery}
|
||||
searchInputRef={searchState.searchInputRef}
|
||||
/>
|
||||
</div>
|
||||
{linkBubble ? (
|
||||
<RichMarkdownLinkBubble
|
||||
linkBubble={linkBubble}
|
||||
isEditing={isEditingLink}
|
||||
onSave={linkBubbleActions.handleLinkSave}
|
||||
onRemove={linkBubbleActions.handleLinkRemove}
|
||||
onEditStart={() => linkBubbleActions.setIsEditingLink(true)}
|
||||
onEditCancel={linkBubbleActions.handleLinkEditCancel}
|
||||
onOpen={linkBubbleActions.handleLinkOpen}
|
||||
/>
|
||||
) : null}
|
||||
{slashMenu ? (
|
||||
<RichMarkdownSlashMenu
|
||||
editor={editor}
|
||||
slashMenu={slashMenu}
|
||||
filteredCommands={filteredSlashCommands}
|
||||
selectedIndex={selectedCommandIndex}
|
||||
onImagePick={onImagePick}
|
||||
onEmojiPick={() => onEmojiPick(slashMenu)}
|
||||
/>
|
||||
) : null}
|
||||
{emojiMenu ? (
|
||||
<RichMarkdownEmojiMenu
|
||||
editor={editor}
|
||||
left={emojiMenu.left}
|
||||
top={emojiMenu.top}
|
||||
onClose={onCloseEmojiMenu}
|
||||
/>
|
||||
) : null}
|
||||
{docLinkMenu ? (
|
||||
<RichMarkdownDocLinkMenu
|
||||
editor={editor}
|
||||
menu={docLinkMenu}
|
||||
rows={docLinkRows}
|
||||
totalMatches={docLinkTotalMatches}
|
||||
selectedIndex={selectedDocLinkIndex}
|
||||
/>
|
||||
) : null}
|
||||
<RichMarkdownAnnotationOverlay
|
||||
target={annotationTarget}
|
||||
popover={annotationPopover}
|
||||
markdownSourceLineOffset={markdownSourceLineOffset}
|
||||
onOpenPopover={onOpenAnnotationPopover}
|
||||
onCancelPopover={onCancelAnnotationPopover}
|
||||
onSubmit={onSubmitAnnotation}
|
||||
/>
|
||||
{markdownCommentsCount > 0 ? (
|
||||
<RichMarkdownReviewRailActions
|
||||
worktreeId={worktreeId}
|
||||
filePath={filePath}
|
||||
noteCount={markdownCommentsCount}
|
||||
railOpen={reviewRailOpen}
|
||||
notesCopied={reviewNotesCopied}
|
||||
unsentScope={unsentMarkdownReviewScope}
|
||||
onToggleRail={onToggleReviewRail}
|
||||
onCopyNotes={onCopyReviewNotes}
|
||||
onDelivered={onReviewNotesDelivered}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{showTableOfContents ? (
|
||||
<MarkdownTableOfContentsPanel
|
||||
items={tableOfContentsItems}
|
||||
onClose={onCloseTableOfContents ?? (() => {})}
|
||||
onNavigate={onNavigateTableOfContentsItem}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<boolean>
|
||||
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 (
|
||||
<div className="rich-markdown-review-note-layer" aria-label="Review notes">
|
||||
{positions.map(({ comment, top }) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
data-rich-markdown-review-note-id={comment.id}
|
||||
className={`rich-markdown-review-note-card ${
|
||||
activeCommentId === comment.id ? 'is-active' : ''
|
||||
} ${attentionCommentId === comment.id ? 'is-attention' : ''}`.trim()}
|
||||
style={{ top }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
if (!isRichMarkdownReviewNoteNavigationClick(event.target)) {
|
||||
return
|
||||
}
|
||||
onScrollSourceIntoView(comment)
|
||||
}}
|
||||
>
|
||||
<DiffCommentCard
|
||||
lineNumber={comment.lineNumber}
|
||||
startLine={comment.startLine}
|
||||
label={null}
|
||||
quote={getMarkdownReviewCardQuote(markdownReviewContent, comment)}
|
||||
body={comment.body}
|
||||
sentAt={comment.sentAt}
|
||||
onDelete={() => onDeleteComment(comment.id)}
|
||||
onSubmitEdit={(body) => onSubmitEdit(comment.id, body)}
|
||||
onContentResize={onContentResize}
|
||||
headerActions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-review-note-action"
|
||||
title={copiedCommentId === comment.id ? 'Copied note' : 'Copy note for agent'}
|
||||
aria-label={
|
||||
copiedCommentId === comment.id ? 'Copied note' : 'Copy note for agent'
|
||||
}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onCopyNote(comment as MarkdownReviewNote)
|
||||
}}
|
||||
>
|
||||
{copiedCommentId === comment.id ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<NotesSendMenu
|
||||
worktreeId={worktreeId}
|
||||
groupId={worktreeId}
|
||||
modeIdParts={['markdown-notes', worktreeId, filePath, 'note', comment.id]}
|
||||
scopes={[
|
||||
{
|
||||
id: 'note',
|
||||
label: 'This note',
|
||||
notes: comment.sentAt ? [] : [comment as MarkdownReviewNote],
|
||||
prompt: formatMarkdownReviewNotes(
|
||||
[comment as MarkdownReviewNote],
|
||||
markdownReviewContent
|
||||
)
|
||||
}
|
||||
]}
|
||||
targetModeLabel="This note"
|
||||
triggerClassName="rich-markdown-review-note-action"
|
||||
disabledTooltip="Note already sent"
|
||||
onDelivered={onDelivered}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<MarkdownReviewNote>[]
|
||||
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 (
|
||||
<div className="rich-markdown-review-rail-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-review-rail-toggle"
|
||||
aria-label={railOpen ? 'Hide review notes' : 'Show review notes'}
|
||||
aria-expanded={railOpen}
|
||||
title={railOpen ? 'Hide review notes' : 'Show review notes'}
|
||||
onClick={onToggleRail}
|
||||
>
|
||||
<MessageSquare className="size-3.5" />
|
||||
<span>{noteCount}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rich-markdown-review-rail-action"
|
||||
title={notesCopied ? 'Copied notes' : 'Copy notes for agent'}
|
||||
aria-label={notesCopied ? 'Copied notes' : 'Copy notes for agent'}
|
||||
onClick={onCopyNotes}
|
||||
>
|
||||
{notesCopied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</button>
|
||||
<NotesSendMenu
|
||||
worktreeId={worktreeId}
|
||||
groupId={worktreeId}
|
||||
modeIdParts={['markdown-notes', worktreeId, filePath, 'rail']}
|
||||
scopes={unsentScope}
|
||||
triggerClassName="rich-markdown-review-rail-action"
|
||||
onDelivered={onDelivered}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, number>
|
||||
isCollapsed: boolean
|
||||
onToggleDirectory: (key: string) => void
|
||||
onNavigate: (entry: CombinedDiffFileTreeEntry) => void
|
||||
}): React.JSX.Element {
|
||||
if (node.type === 'directory') {
|
||||
return (
|
||||
<div
|
||||
className="group relative flex w-full items-center gap-1 py-1 pr-3 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
|
||||
style={{
|
||||
paddingLeft: `${node.depth * COMBINED_DIFF_TREE_INDENT_PX + COMBINED_DIFF_TREE_DIRECTORY_PADDING_PX}px`
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path))
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left"
|
||||
onClick={() => onToggleDirectory(node.key)}
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('size-3 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
|
||||
/>
|
||||
{isCollapsed ? (
|
||||
<Folder className="size-3 shrink-0" />
|
||||
) : (
|
||||
<FolderOpen className="size-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{node.name}</span>
|
||||
</button>
|
||||
<span className="w-4 shrink-0 text-center text-[10px] font-bold tabular-nums text-muted-foreground/80">
|
||||
{node.fileCount}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'group flex w-full min-w-0 cursor-pointer items-center gap-1 py-1 pr-3 text-left text-xs transition-colors hover:bg-accent/40 disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent',
|
||||
activeSectionKey === sectionKey && 'bg-accent/60'
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: `${node.depth * COMBINED_DIFF_TREE_INDENT_PX + COMBINED_DIFF_TREE_FILE_PADDING_PX}px`
|
||||
}}
|
||||
disabled={disabled}
|
||||
draggable={!disabled}
|
||||
onDragStart={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
event.dataTransfer.setData(
|
||||
WORKSPACE_FILE_PATH_MIME,
|
||||
joinPath(worktreePath, node.entry.path)
|
||||
)
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
onClick={() => onNavigate(node.entry)}
|
||||
>
|
||||
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[status] }} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span className="text-foreground">{fileName}</span>
|
||||
{dirPath && <span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>}
|
||||
</span>
|
||||
<span
|
||||
className="w-4 shrink-0 text-center text-[10px] font-bold"
|
||||
style={{ color: STATUS_COLORS[status] }}
|
||||
>
|
||||
{STATUS_LABELS[status]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<React.SetStateAction<SlashMenuState | null>>
|
||||
): 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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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<unknown>
|
||||
|
||||
export type RichMarkdownRuntimeSettings = Parameters<typeof settingsForRuntimeOwner>[0]
|
||||
|
||||
type RichMarkdownEditorClickRoutingOptions = {
|
||||
activateMarkdownLink: ActivateMarkdownLink
|
||||
editorRef: MutableRefObject<Editor | null>
|
||||
event: MouseEvent
|
||||
filePath: string
|
||||
isMac: boolean
|
||||
markdownCommentsRef: MutableRefObject<DiffComment[]>
|
||||
markdownSourceLineOffsetRef: MutableRefObject<number>
|
||||
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
|
||||
pos: number
|
||||
rootRef: MutableRefObject<HTMLDivElement | null>
|
||||
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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SetStateAction<RichMarkdownReviewNotePosition[]>>
|
||||
): 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<RichMarkdownAnnotationTarget, 'lineNumber' | 'selectedText' | 'startLine'>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<string, number> {
|
||||
const measuredHeights = new Map<string, number>()
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<React.SetStateAction<SlashMenuState | null>>
|
||||
): 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
|
||||
})
|
||||
}
|
||||
@@ -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<typeof computeLineStats> | 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 }
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>
|
||||
editorRef: MutableRefObject<Editor | null>
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>
|
||||
onContentChangeRef: MutableRefObject<(content: string) => void>
|
||||
onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void>
|
||||
onSaveRef: MutableRefObject<(content: string) => void>
|
||||
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
|
||||
isEditingLinkRef: MutableRefObject<boolean>
|
||||
slashMenuRef: MutableRefObject<SlashMenuState | null>
|
||||
filteredSlashCommandsRef: MutableRefObject<SlashCommand[]>
|
||||
selectedCommandIndexRef: MutableRefObject<number>
|
||||
docLinkMenuRef: MutableRefObject<DocLinkMenuState | null>
|
||||
filteredDocLinkRowsRef: MutableRefObject<DocLinkMenuRow[]>
|
||||
selectedDocLinkIndexRef: MutableRefObject<number>
|
||||
handleLocalImagePickRef: MutableRefObject<() => void>
|
||||
handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void>
|
||||
typedEmptyOrderedListMarkerRef: MutableRefObject<boolean>
|
||||
cancelAutoFocusRef: MutableRefObject<(() => void) | null>
|
||||
serializeTimerRef: MutableRefObject<number | null>
|
||||
isInitializingRef: MutableRefObject<boolean>
|
||||
isApplyingProgrammaticUpdateRef: MutableRefObject<boolean>
|
||||
markdownCommentsRef: MutableRefObject<DiffComment[]>
|
||||
markdownSourceLineOffsetRef: MutableRefObject<number>
|
||||
flushPendingSerialization: () => void
|
||||
openSearchRef: MutableRefObject<() => void>
|
||||
syncAnnotationTarget: (editor: Editor) => void
|
||||
clearAnnotationTarget: () => void
|
||||
scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void
|
||||
setIsEditingLink: Dispatch<SetStateAction<boolean>>
|
||||
setLinkBubble: Dispatch<SetStateAction<LinkBubbleState | null>>
|
||||
setSelectedCommandIndex: Dispatch<SetStateAction<number>>
|
||||
setSelectedDocLinkIndex: Dispatch<SetStateAction<number>>
|
||||
setSlashMenu: Dispatch<SetStateAction<SlashMenuState | null>>
|
||||
setDocLinkMenu: Dispatch<SetStateAction<DocLinkMenuState | null>>
|
||||
}): 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
|
||||
}
|
||||
@@ -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<SlashMenuState | null>(null)
|
||||
const [slashSelection, setSlashSelection] = useState<MenuSelection>({ query: null, index: 0 })
|
||||
const [docLinkMenu, setDocLinkMenu] = useState<DocLinkMenuState | null>(null)
|
||||
const [docLinkSelection, setDocLinkSelection] = useState<MenuSelection>({ query: null, index: 0 })
|
||||
const [emojiMenu, setEmojiMenu] = useState<{ left: number; top: number } | null>(null)
|
||||
const slashMenuRef = useRef<SlashMenuState | null>(null)
|
||||
const filteredSlashCommandsRef = useRef<SlashCommand[]>(slashCommands)
|
||||
const selectedCommandIndexRef = useRef(0)
|
||||
const docLinkMenuRef = useRef<DocLinkMenuState | null>(null)
|
||||
const filteredDocLinkRowsRef = useRef<DocLinkMenuRow[]>([])
|
||||
const selectedDocLinkIndexRef = useRef(0)
|
||||
const handleEmojiPickRef = useRef<(menu: SlashMenuState) => void>(() => {})
|
||||
|
||||
slashMenuRef.current = slashMenu
|
||||
docLinkMenuRef.current = docLinkMenu
|
||||
|
||||
const setSelectedCommandIndex = useCallback<React.Dispatch<React.SetStateAction<number>>>(
|
||||
(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<React.Dispatch<React.SetStateAction<number>>>(
|
||||
(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)
|
||||
}
|
||||
@@ -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<SetStateAction<DocLinkMenuState | null>>
|
||||
editor: Editor | null
|
||||
fileId: string
|
||||
filePath: string
|
||||
isApplyingProgrammaticUpdateRef: MutableRefObject<boolean>
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>
|
||||
markdownDocuments?: MarkdownDocument[]
|
||||
rootRef: MutableRefObject<HTMLDivElement | null>
|
||||
runtimeEnvironmentId?: string | null
|
||||
settings: Parameters<typeof settingsForRuntimeOwner>[0]
|
||||
slashMenuSetter: Dispatch<SetStateAction<SlashMenuState | null>>
|
||||
worktreeId: string
|
||||
worktreeRoot: string | null
|
||||
}
|
||||
|
||||
type RichMarkdownEditorStorage = {
|
||||
image: {
|
||||
filePath: string
|
||||
runtimeContext?: {
|
||||
connectionId: string | null | undefined
|
||||
settings: ReturnType<typeof settingsForRuntimeOwner>
|
||||
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<string>
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
@@ -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<Editor | null>
|
||||
filePath: string
|
||||
markdownAnnotationFilePath?: string
|
||||
markdownAnnotationsEnabled: boolean
|
||||
markdownReviewContent: string
|
||||
markdownSourceLineOffset: number
|
||||
rootRef: MutableRefObject<HTMLDivElement | null>
|
||||
scrollContainerRef: MutableRefObject<HTMLDivElement | null>
|
||||
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<RichMarkdownAnnotationTarget | null>(
|
||||
null
|
||||
)
|
||||
const [annotationPopover, setAnnotationPopover] = useState<RichMarkdownAnnotationTarget | null>(
|
||||
null
|
||||
)
|
||||
const annotationPopoverRef = useRef<RichMarkdownAnnotationTarget | null>(null)
|
||||
const canAnnotateRichMarkdownRef = useRef(false)
|
||||
const markdownCommentsRef = useRef<DiffComment[]>([])
|
||||
const markdownSourceLineOffsetRef = useRef(markdownSourceLineOffset)
|
||||
const annotationTargetFrameRef = useRef<number | null>(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<void> => {
|
||||
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<number | null>): void {
|
||||
if (ref.current !== null) {
|
||||
window.cancelAnimationFrame(ref.current)
|
||||
ref.current = null
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function useRichMarkdownReviewCopyFeedback({
|
||||
markdownReviewContent,
|
||||
markdownReviewNotes,
|
||||
rootRef
|
||||
}: UseRichMarkdownReviewCopyFeedbackOptions) {
|
||||
const [reviewNotesCopied, setReviewNotesCopied] = useState(false)
|
||||
const [copiedReviewNoteId, setCopiedReviewNoteId] = useState<string | null>(null)
|
||||
const reviewNotesCopiedResetTimerRef = useRef<number | null>(null)
|
||||
const copiedReviewNoteResetTimerRef = useRef<number | null>(null)
|
||||
|
||||
const clearReviewCopyTimers = useCallback((): void => {
|
||||
clearWindowTimer(reviewNotesCopiedResetTimerRef)
|
||||
clearWindowTimer(copiedReviewNoteResetTimerRef)
|
||||
}, [])
|
||||
|
||||
const handleCopyMarkdownReviewNotes = useCallback(async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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<number | null>): void {
|
||||
if (ref.current !== null) {
|
||||
window.clearTimeout(ref.current)
|
||||
ref.current = null
|
||||
}
|
||||
}
|
||||
|
||||
async function copyReviewNotes(notes: MarkdownReviewNote[], content: string): Promise<boolean> {
|
||||
try {
|
||||
return await copyMarkdownReviewNotesForAgent({
|
||||
notes,
|
||||
content,
|
||||
writeClipboardText: window.api.ui.writeClipboardText
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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<MarkdownReviewNote>[]
|
||||
} {
|
||||
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<NotesSendMenuScope<MarkdownReviewNote>[]>(() => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>
|
||||
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])
|
||||
}
|
||||
@@ -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<Editor | null>
|
||||
markdownComments: DiffComment[]
|
||||
markdownSourceLineOffset: number
|
||||
markdownSourceLineOffsetRef: MutableRefObject<number>
|
||||
scrollContainerRef: MutableRefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function useRichMarkdownReviewRailController({
|
||||
canAnnotateRichMarkdown,
|
||||
content,
|
||||
editorRef,
|
||||
markdownComments,
|
||||
markdownSourceLineOffset,
|
||||
markdownSourceLineOffsetRef,
|
||||
scrollContainerRef
|
||||
}: UseRichMarkdownReviewRailControllerOptions) {
|
||||
const [reviewRailOpen, setReviewRailOpen] = useState(false)
|
||||
const [activeReviewCommentId, setActiveReviewCommentId] = useState<string | null>(null)
|
||||
const [attentionReviewCommentId, setAttentionReviewCommentId] = useState<string | null>(null)
|
||||
const [notePositions, setNotePositions] = useState<RichMarkdownReviewNotePosition[]>([])
|
||||
const notePositionsRef = useRef<RichMarkdownReviewNotePosition[]>([])
|
||||
const attentionReviewCommentTimeoutRef = useRef<number | null>(null)
|
||||
const sourceAttentionTimeoutRef = useRef<number | null>(null)
|
||||
const notePositionsFrameRef = useRef<number | null>(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<number | null>): void {
|
||||
if (ref.current !== null) {
|
||||
window.clearTimeout(ref.current)
|
||||
ref.current = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelFrame(ref: MutableRefObject<number | null>): 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<HTMLElement>(
|
||||
`[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' })
|
||||
}
|
||||
@@ -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<string>(['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<HTMLButtonElement>, 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
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-col gap-1.5 min-w-0 flex-1">
|
||||
<WorkspaceListCard
|
||||
<SetupScriptWorkspaceListCard
|
||||
title="release notes"
|
||||
active={!isWorkspaceActive}
|
||||
prompt="draft release notes"
|
||||
@@ -166,7 +168,7 @@ export function SetupScriptAnimatedVisual(props: { reducedMotion: boolean }): JS
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
|
||||
<WorkspaceListCard
|
||||
<SetupScriptWorkspaceListCard
|
||||
title="checkout fix"
|
||||
active={isWorkspaceActive}
|
||||
prompt="fix checkout timeout"
|
||||
@@ -319,7 +321,7 @@ export function SetupScriptAnimatedVisual(props: { reducedMotion: boolean }): JS
|
||||
</div>
|
||||
|
||||
{/* Create Worktree Modal Overlay */}
|
||||
<NewWorkspaceModal
|
||||
<SetupScriptNewWorkspaceModal
|
||||
visible={modalVisible}
|
||||
nameValue={modalNameValue}
|
||||
nameTyping={phase === 'modal-typing'}
|
||||
@@ -383,112 +385,6 @@ function useMeasuredCursor(
|
||||
return pos
|
||||
}
|
||||
|
||||
function WorkspaceListCard(props: {
|
||||
title: string
|
||||
active: boolean
|
||||
prompt?: string
|
||||
icon?: ReactNode
|
||||
state: 'idle' | 'starting' | 'setup' | 'working'
|
||||
reducedMotion: boolean
|
||||
className?: string
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-sidebar-border px-2 py-1.5 transition-colors duration-300',
|
||||
props.active ? 'bg-sidebar-accent' : 'bg-sidebar',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full transition-colors duration-300',
|
||||
props.active ? 'bg-emerald-500' : 'bg-muted-foreground/35'
|
||||
)}
|
||||
/>
|
||||
<span className="truncate text-xs font-semibold text-sidebar-foreground">
|
||||
{props.title}
|
||||
</span>
|
||||
</div>
|
||||
{props.prompt && (
|
||||
<div className="mt-1.5 grid grid-cols-[8px_14px_minmax(0,1fr)] items-center gap-1.5">
|
||||
{props.state === 'working' || props.state === 'starting' || props.state === 'setup' ? (
|
||||
<WorkingSpinner size="xs" reducedMotion={props.reducedMotion} />
|
||||
) : (
|
||||
<span className="size-1.5 rounded-full bg-muted-foreground/35" />
|
||||
)}
|
||||
<span className="flex size-3.5 items-center justify-center text-sidebar-foreground/65">
|
||||
{props.icon}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[10px] text-sidebar-foreground/65">
|
||||
{props.state === 'setup' ? 'running setup' : props.prompt}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewWorkspaceModal(props: {
|
||||
visible: boolean
|
||||
nameValue: string
|
||||
nameTyping: boolean
|
||||
createHovered: boolean
|
||||
createClicked: boolean
|
||||
createButtonRef: RefObject<HTMLDivElement | null>
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 flex items-center justify-center rounded-lg bg-background/55 backdrop-blur-[1px] transition-opacity duration-300 z-40',
|
||||
props.visible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-[min(240px,92%)] rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-lg transition-[opacity,transform] duration-300',
|
||||
props.visible
|
||||
? 'translate-y-0 scale-100 opacity-100'
|
||||
: 'translate-y-2 scale-[0.98] opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className="text-xs font-semibold leading-none text-foreground">Create Worktree</div>
|
||||
<div className="mt-2.5 space-y-1.5">
|
||||
<ModalField label="Project" value="orca" />
|
||||
<ModalField label="Name" value={props.nameValue} typing={props.nameTyping} />
|
||||
<ModalField label="Agent" value="Codex" />
|
||||
</div>
|
||||
<div
|
||||
ref={props.createButtonRef}
|
||||
className={cn(
|
||||
'mt-3 flex h-7 w-full items-center justify-center rounded-md bg-primary px-3 text-[11px] font-medium text-primary-foreground transition-all duration-200',
|
||||
props.createHovered ? 'opacity-90' : null,
|
||||
props.createClicked ? 'scale-[0.98]' : null
|
||||
)}
|
||||
>
|
||||
Create worktree
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModalField(props: { label: string; value: string; typing?: boolean }): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-2 py-1 text-[11px]">
|
||||
<span className="text-muted-foreground">{props.label}</span>
|
||||
<div className="flex items-center font-mono font-medium text-foreground min-w-0">
|
||||
<span className="truncate">{props.value}</span>
|
||||
{props.typing ? (
|
||||
<span className="ml-px inline-block h-2.5 w-[5px] animate-pulse bg-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TerminalLine(props: {
|
||||
children: ReactNode
|
||||
muted?: boolean
|
||||
|
||||
@@ -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<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function SetupScriptNewWorkspaceModal({
|
||||
visible,
|
||||
nameValue,
|
||||
nameTyping,
|
||||
createHovered,
|
||||
createClicked,
|
||||
createButtonRef
|
||||
}: SetupScriptNewWorkspaceModalProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 flex items-center justify-center rounded-lg bg-background/55 backdrop-blur-[1px] transition-opacity duration-300 z-40',
|
||||
visible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-[min(240px,92%)] rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-lg transition-[opacity,transform] duration-300',
|
||||
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-[0.98] opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className="text-xs font-semibold leading-none text-foreground">Create Worktree</div>
|
||||
<div className="mt-2.5 space-y-1.5">
|
||||
<SetupScriptModalField label="Project" value="orca" />
|
||||
<SetupScriptModalField label="Name" value={nameValue} typing={nameTyping} />
|
||||
<SetupScriptModalField label="Agent" value="Codex" />
|
||||
</div>
|
||||
<div
|
||||
ref={createButtonRef}
|
||||
className={cn(
|
||||
'mt-3 flex h-7 w-full items-center justify-center rounded-md bg-primary px-3 text-[11px] font-medium text-primary-foreground transition-all duration-200',
|
||||
createHovered ? 'opacity-90' : null,
|
||||
createClicked ? 'scale-[0.98]' : null
|
||||
)}
|
||||
>
|
||||
Create worktree
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SetupScriptModalField(props: {
|
||||
label: string
|
||||
value: string
|
||||
typing?: boolean
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-2 py-1 text-[11px]">
|
||||
<span className="text-muted-foreground">{props.label}</span>
|
||||
<div className="flex items-center font-mono font-medium text-foreground min-w-0">
|
||||
<span className="truncate">{props.value}</span>
|
||||
{props.typing ? (
|
||||
<span className="ml-px inline-block h-2.5 w-[5px] animate-pulse bg-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-sidebar-border px-2 py-1.5 transition-colors duration-300',
|
||||
props.active ? 'bg-sidebar-accent' : 'bg-sidebar',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full transition-colors duration-300',
|
||||
props.active ? 'bg-emerald-500' : 'bg-muted-foreground/35'
|
||||
)}
|
||||
/>
|
||||
<span className="truncate text-xs font-semibold text-sidebar-foreground">
|
||||
{props.title}
|
||||
</span>
|
||||
</div>
|
||||
{props.prompt ? (
|
||||
<div className="mt-1.5 grid grid-cols-[8px_14px_minmax(0,1fr)] items-center gap-1.5">
|
||||
{props.state === 'working' || props.state === 'starting' || props.state === 'setup' ? (
|
||||
<WorkingSpinner size="xs" reducedMotion={props.reducedMotion} />
|
||||
) : (
|
||||
<span className="size-1.5 rounded-full bg-muted-foreground/35" />
|
||||
)}
|
||||
<span className="flex size-3.5 items-center justify-center text-sidebar-foreground/65">
|
||||
{props.icon}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[10px] text-sidebar-foreground/65">
|
||||
{props.state === 'setup' ? 'running setup' : props.prompt}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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<string>(['ArrowUp', 'ArrowDown', 'Home', 'End'])
|
||||
|
||||
export function useFeatureWallTourRailKeydown({
|
||||
railRefs,
|
||||
onSelectWorkflow
|
||||
}: {
|
||||
railRefs: RefObject<(HTMLButtonElement | null)[]>
|
||||
onSelectWorkflow: (workflow: FeatureWallWorkflow) => void
|
||||
}): (event: KeyboardEvent<HTMLButtonElement>, index: number) => void {
|
||||
return useCallback(
|
||||
(event: KeyboardEvent<HTMLButtonElement>, 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]
|
||||
)
|
||||
}
|
||||
@@ -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<string> {
|
||||
return QRCodeBrowser.toDataURL(text, {
|
||||
@@ -35,22 +32,6 @@ async function renderQrDataUrl(text: string): Promise<string> {
|
||||
})
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mobile-page-root">
|
||||
<div className="mp-page-toolbar">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 rounded-full"
|
||||
onClick={closeMobilePage}
|
||||
aria-label="Close Orca Mobile"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Close · Esc
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-2 rounded-md bg-card px-3 text-xs font-medium shadow-xs"
|
||||
onClick={toggleMobileSidebarButton}
|
||||
>
|
||||
{showMobileButton ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
{showMobileButton ? 'Hide from sidebar' : 'Show in sidebar'}
|
||||
</Button>
|
||||
</div>
|
||||
<MobilePageToolbar
|
||||
showMobileButton={showMobileButton}
|
||||
onClose={closeMobilePage}
|
||||
onToggleMobileSidebarButton={toggleMobileSidebarButton}
|
||||
/>
|
||||
<section className="mp-hero">
|
||||
<div className="mp-hero-copy">
|
||||
{stage === null ? null : stage === 'intro' ? (
|
||||
|
||||
@@ -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 (
|
||||
<div className="mp-page-toolbar">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 rounded-full"
|
||||
onClick={onClose}
|
||||
aria-label="Close Orca Mobile"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Close · Esc
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-2 rounded-md bg-card px-3 text-xs font-medium shadow-xs"
|
||||
onClick={onToggleMobileSidebarButton}
|
||||
>
|
||||
{showMobileButton ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
{showMobileButton ? 'Hide from sidebar' : 'Show in sidebar'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 pr-8">
|
||||
<DialogTitle className="min-w-0 truncate">Create {copy.titleLabel}</DialogTitle>
|
||||
{aiGenerationEnabled ? (
|
||||
<div className="shrink-0">
|
||||
{generating ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelGenerate}
|
||||
title="Stop generating"
|
||||
aria-label={`Stop generating ${copy.reviewLabel} details`}
|
||||
>
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
Generating…
|
||||
<Square className="size-3 fill-current" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
Generating {copy.shortLabel} details. Click to stop.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={generateDisabled}
|
||||
onClick={() => void handleGenerate()}
|
||||
title={generateDisabledReason ?? `Generate ${copy.reviewLabel} details with AI`}
|
||||
aria-label={`Generate ${copy.reviewLabel} details with AI`}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
Generate with AI
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CreatePullRequestGenerateButton
|
||||
generating={generating}
|
||||
generateDisabled={generateDisabled}
|
||||
generateDisabledReason={generateDisabledReason}
|
||||
shortLabel={copy.shortLabel}
|
||||
reviewLabel={copy.reviewLabel}
|
||||
onGenerate={() => void handleGenerate()}
|
||||
onCancelGenerate={handleCancelGenerate}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -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 (
|
||||
<div className="shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancelGenerate}
|
||||
title="Stop generating"
|
||||
aria-label={`Stop generating ${reviewLabel} details`}
|
||||
>
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
Generating…
|
||||
<Square className="size-3 fill-current" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={6}>
|
||||
Generating {shortLabel} details. Click to stop.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={generateDisabled}
|
||||
onClick={onGenerate}
|
||||
title={generateDisabledReason ?? `Generate ${reviewLabel} details with AI`}
|
||||
aria-label={`Generate ${reviewLabel} details with AI`}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
Generate with AI
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
|
||||
type GitLabMRMergeStateReview = Pick<HostedReviewInfo, 'state' | 'status' | 'mergeable'>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-2 space-y-3 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="git-auto-rename-branch-name-prompt">Branch name prompt</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Appended to Orca's{' '}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline rounded-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
built-in branch-name prompt
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
className="w-[520px] max-w-[calc(100vw-2rem)] p-3"
|
||||
>
|
||||
<div>
|
||||
<pre className="scrollbar-sleek max-h-72 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
|
||||
{BUILT_IN_BRANCH_NAME_PROMPT}
|
||||
</pre>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
. Orca generates only the final segment, like{' '}
|
||||
<code className="font-mono">fix-login-flow</code>; your branch prefix setting
|
||||
still applies.
|
||||
</p>
|
||||
</div>
|
||||
<textarea
|
||||
id="git-auto-rename-branch-name-prompt"
|
||||
rows={4}
|
||||
value={branchNamePromptDraft}
|
||||
onChange={(event) => setBranchNamePromptDraft(event.target.value)}
|
||||
placeholder="Prefer domain nouns from the task, avoid ticket IDs, and keep names reviewer-friendly."
|
||||
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{branchNamePromptDirty ? 'Unsaved changes' : 'Saved'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{branchNamePromptDirty ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onDiscardPrompt}
|
||||
disabled={isSavingPrompt}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => void onSavePrompt()}
|
||||
disabled={!branchNamePromptDirty || isSavingPrompt}
|
||||
>
|
||||
{isSavingPrompt ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AutoRenameBranchPromptEditor
|
||||
draft={branchNamePromptDraft}
|
||||
dirty={branchNamePromptDirty}
|
||||
saving={isSavingPrompt}
|
||||
onDraftChange={setBranchNamePromptDraft}
|
||||
onDiscard={onDiscardPrompt}
|
||||
onSave={onSavePrompt}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border/50 pt-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-0.5">
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { buildBranchNamePrompt } from '../../../../shared/branch-name-from-work'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
|
||||
|
||||
const BUILT_IN_BRANCH_NAME_PROMPT = buildBranchNamePrompt({
|
||||
firstPrompt: '{first agent prompt}',
|
||||
assistantMessage: '{agent initial response, when available}'
|
||||
})
|
||||
|
||||
type AutoRenameBranchPromptEditorProps = {
|
||||
draft: string
|
||||
dirty: boolean
|
||||
saving: boolean
|
||||
onDraftChange: (value: string) => void
|
||||
onDiscard: () => void
|
||||
onSave: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function AutoRenameBranchPromptEditor({
|
||||
draft,
|
||||
dirty,
|
||||
saving,
|
||||
onDraftChange,
|
||||
onDiscard,
|
||||
onSave
|
||||
}: AutoRenameBranchPromptEditorProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="git-auto-rename-branch-name-prompt">Branch name prompt</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Appended to Orca's{' '}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline rounded-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
built-in branch-name prompt
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
className="w-[520px] max-w-[calc(100vw-2rem)] p-3"
|
||||
>
|
||||
<div>
|
||||
<pre className="scrollbar-sleek max-h-72 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
|
||||
{BUILT_IN_BRANCH_NAME_PROMPT}
|
||||
</pre>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
. Orca generates only the final segment, like{' '}
|
||||
<code className="font-mono">fix-login-flow</code>; your branch prefix setting still
|
||||
applies.
|
||||
</p>
|
||||
</div>
|
||||
<textarea
|
||||
id="git-auto-rename-branch-name-prompt"
|
||||
rows={4}
|
||||
value={draft}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
placeholder="Prefer domain nouns from the task, avoid ticket IDs, and keep names reviewer-friendly."
|
||||
className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-muted-foreground">{dirty ? 'Unsaved changes' : 'Saved'}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{dirty ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={onDiscard} disabled={saving}>
|
||||
Discard
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => void onSave()}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MousePointerClick } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
export function BrowserUseComputerUseNotice({
|
||||
onOpenComputerUse
|
||||
}: {
|
||||
onOpenComputerUse: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">Use an existing browser session</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If cookie import is not the right fit, Computer Use can control local apps and may use
|
||||
existing logged-in browser sessions where applicable. Install the Computer Use skill;
|
||||
macOS also requires privacy permissions.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenComputerUse}
|
||||
className="shrink-0 gap-1.5 self-start"
|
||||
>
|
||||
<MousePointerClick className="size-3.5" />
|
||||
Open Computer Use
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function BrowserUseEnableSwitch({
|
||||
enabled,
|
||||
onToggle
|
||||
}: {
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label="Enable Agent Browser Use"
|
||||
onClick={onToggle}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
enabled ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
|
||||
enabled ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: Browser Use setup keeps enablement, CLI registration, skill install, cookie import, examples, and interaction tracking in one pane so the three-step setup state stays coherent. */
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Import, Loader2, MousePointerClick } from 'lucide-react'
|
||||
import { Import, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
|
||||
import {
|
||||
@@ -37,6 +36,8 @@ import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { BROWSER_USE_PANE_SEARCH_ENTRIES } from './browser-use-search'
|
||||
import { BrowserUseExamples } from './BrowserUseExamples'
|
||||
import { BrowserUseComputerUseNotice } from './BrowserUseComputerUseNotice'
|
||||
import { BrowserUseEnableSwitch } from './BrowserUseEnableSwitch'
|
||||
import { StepBadge } from './BrowserUseStepBadge'
|
||||
import { BrowserUseSkillStep } from './BrowserUseSkillStep'
|
||||
|
||||
@@ -188,24 +189,6 @@ export function BrowserUseSetup({
|
||||
? `${BROWSER_FAMILY_LABELS[defaultProfile.source.browserFamily] ?? defaultProfile.source.browserFamily}${defaultProfile.source.profileName ? ` (${defaultProfile.source.profileName})` : ''}`
|
||||
: null
|
||||
|
||||
const toggleSwitch = (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={browserUseEnabled}
|
||||
aria-label="Enable Agent Browser Use"
|
||||
onClick={() => toggleBrowserUse(!browserUseEnabled)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
browserUseEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
|
||||
browserUseEnabled ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!browserUseEnabled) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
@@ -215,7 +198,10 @@ export function BrowserUseSetup({
|
||||
Let coding agents drive this browser with your logins.
|
||||
</p>
|
||||
</div>
|
||||
{toggleSwitch}
|
||||
<BrowserUseEnableSwitch
|
||||
enabled={browserUseEnabled}
|
||||
onToggle={() => toggleBrowserUse(!browserUseEnabled)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -239,33 +225,15 @@ export function BrowserUseSetup({
|
||||
>
|
||||
{completedCount}/3
|
||||
</span>
|
||||
{toggleSwitch}
|
||||
<BrowserUseEnableSwitch
|
||||
enabled={browserUseEnabled}
|
||||
onToggle={() => toggleBrowserUse(!browserUseEnabled)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onOpenComputerUse ? (
|
||||
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">Use an existing browser session</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If cookie import is not the right fit, Computer Use can control local apps and may
|
||||
use existing logged-in browser sessions where applicable. Install the Computer Use
|
||||
skill; macOS also requires privacy permissions.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenComputerUse}
|
||||
className="shrink-0 gap-1.5 self-start"
|
||||
>
|
||||
<MousePointerClick className="size-3.5" />
|
||||
Open Computer Use
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<BrowserUseComputerUseNotice onOpenComputerUse={onOpenComputerUse} />
|
||||
) : null}
|
||||
|
||||
{showStep1 ? (
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { LoaderCircle } from 'lucide-react'
|
||||
import type { PtyManagementSession } from '../../../../preload/api-types'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
|
||||
type ManageSessionKillDialogProps = {
|
||||
session: PtyManagementSession | null
|
||||
isBusy: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function ManageSessionKillDialog({
|
||||
session,
|
||||
isBusy,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: ManageSessionKillDialogProps): React.JSX.Element {
|
||||
return (
|
||||
<Dialog
|
||||
open={session !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
return
|
||||
}
|
||||
// Why: destructive terminal mutations should keep their progress
|
||||
// dialog open until the daemon responds, matching other confirm flows.
|
||||
if (isBusy) {
|
||||
return
|
||||
}
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
showCloseButton={!isBusy}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (isBusy) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isBusy) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Kill this session?</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Force-quits <span className="font-medium text-foreground">{session.sessionId}</span>
|
||||
. Any unsaved work in that pane is lost. This can't be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isBusy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={isBusy}>
|
||||
{isBusy ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
{isBusy ? 'Killing…' : 'Kill session'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,8 @@
|
||||
/* eslint-disable max-lines -- Why: this pane owns all admin controls for the
|
||||
pty daemon (list, kill-all, kill-one, restart) plus the confirmation
|
||||
dialog and table. Splitting would scatter the shared action state and
|
||||
toast copy across files without a cleaner ownership seam. */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { LoaderCircle, RefreshCw, RotateCw, Trash2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { PtyManagementSession } from '../../../../preload/api-types'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { MANAGE_SESSIONS_SEARCH_ENTRIES } from './terminal-search'
|
||||
@@ -23,11 +11,10 @@ import { useAppStore } from '../../store'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
|
||||
import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions'
|
||||
import { ManageSessionKillDialog } from './ManageSessionKillDialog'
|
||||
|
||||
type ConfirmKind = 'killOne'
|
||||
|
||||
type PendingConfirm = { kind: 'killOne'; session: PtyManagementSession } | null
|
||||
|
||||
// Why: mirror the status-bar SessionsStatusSegment label style — last two
|
||||
// path segments joined by the platform separator, no ellipsis prefix. This
|
||||
// keeps the Manage Sessions table visually consistent with how Orca labels
|
||||
@@ -73,28 +60,6 @@ function formatState(session: PtyManagementSession): string {
|
||||
return session.state
|
||||
}
|
||||
|
||||
function getConfirmCopy(confirm: PendingConfirm): {
|
||||
title: string
|
||||
description: React.ReactNode
|
||||
confirmLabel: string
|
||||
busyLabel: string
|
||||
} | null {
|
||||
if (!confirm) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
title: 'Kill this session?',
|
||||
description: (
|
||||
<>
|
||||
Force-quits <span className="font-medium text-foreground">{confirm.session.sessionId}</span>
|
||||
. Any unsaved work in that pane is lost. This can't be undone.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Kill session',
|
||||
busyLabel: 'Killing…'
|
||||
}
|
||||
}
|
||||
|
||||
export function ManageSessionsSection(): React.JSX.Element {
|
||||
const activeRuntimeEnvironmentId = useAppStore(
|
||||
(s) => s.settings?.activeRuntimeEnvironmentId ?? null
|
||||
@@ -102,7 +67,7 @@ export function ManageSessionsSection(): React.JSX.Element {
|
||||
const [sessions, setSessions] = useState<PtyManagementSession[]>([])
|
||||
const [isRefreshing, setIsRefreshing] = useState(true)
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
|
||||
const [confirm, setConfirm] = useState<PendingConfirm>(null)
|
||||
const [pendingKillSession, setPendingKillSession] = useState<PtyManagementSession | null>(null)
|
||||
const [busyKind, setBusyKind] = useState<ConfirmKind | null>(null)
|
||||
// Why: optimistic UI snapshot for Kill-all rollback. The design doc calls
|
||||
// for emptying the list immediately and restoring it on error so users see
|
||||
@@ -265,25 +230,24 @@ export function ManageSessionsSection(): React.JSX.Element {
|
||||
mutationInFlight.current = false
|
||||
if (isMounted.current) {
|
||||
setBusyKind(null)
|
||||
setConfirm(null)
|
||||
setPendingKillSession(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
// Why: do NOT clear `confirm` here — the dialog must stay open for the
|
||||
// Why: do NOT clear `pendingKillSession` here — the dialog must stay open for the
|
||||
// duration of the mutation so the spinner + busyLabel render and the
|
||||
// anti-dismiss guard can actually hold. handleKillOne's `finally` clears
|
||||
// `confirm` when the op resolves.
|
||||
// `pendingKillSession` when the op resolves.
|
||||
const runConfirmed = useCallback(() => {
|
||||
if (!confirm) {
|
||||
if (!pendingKillSession) {
|
||||
return
|
||||
}
|
||||
void handleKillOne(confirm.session)
|
||||
}, [confirm, handleKillOne])
|
||||
void handleKillOne(pendingKillSession)
|
||||
}, [pendingKillSession, handleKillOne])
|
||||
|
||||
const copy = useMemo(() => getConfirmCopy(confirm), [confirm])
|
||||
const isBusy = busyKind !== null || daemonActions.isBusy
|
||||
|
||||
if (activeRuntimeEnvironmentId?.trim()) {
|
||||
@@ -465,7 +429,7 @@ export function ManageSessionsSection(): React.JSX.Element {
|
||||
// without stopPropagation the kill click would
|
||||
// also fire handleNavigate and dismiss Settings.
|
||||
e.stopPropagation()
|
||||
setConfirm({ kind: 'killOne', session })
|
||||
setPendingKillSession(session)
|
||||
}}
|
||||
disabled={isBusy}
|
||||
aria-label={`Kill session ${session.sessionId}`}
|
||||
@@ -484,55 +448,12 @@ export function ManageSessionsSection(): React.JSX.Element {
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
|
||||
<Dialog
|
||||
open={confirm !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
return
|
||||
}
|
||||
// Why: don't let overlay click / Escape / close-button close the
|
||||
// dialog mid-mutation — the confirm button is the canonical exit.
|
||||
// Matches the "can't cancel a destructive op in flight" convention
|
||||
// in other confirm dialogs across the app.
|
||||
if (isBusy) {
|
||||
return
|
||||
}
|
||||
setConfirm(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
showCloseButton={!isBusy}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isBusy) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (isBusy) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copy ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{copy.title}</DialogTitle>
|
||||
<DialogDescription className="text-xs">{copy.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirm(null)} disabled={isBusy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={runConfirmed} disabled={isBusy}>
|
||||
{isBusy ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||
{isBusy ? copy.busyLabel : copy.confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ManageSessionKillDialog
|
||||
session={pendingKillSession}
|
||||
isBusy={isBusy}
|
||||
onCancel={() => setPendingKillSession(null)}
|
||||
onConfirm={runConfirmed}
|
||||
/>
|
||||
<DaemonActionDialog api={daemonActions} />
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Label } from '../ui/label'
|
||||
|
||||
export type NotificationSettingToggleProps = {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onToggle: () => void
|
||||
disabled?: boolean
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export function NotificationSettingToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
icon
|
||||
}: NotificationSettingToggleProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<Label>{label}</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
|
||||
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
checked ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: notification settings keeps delivery toggles, system test feedback, and sound selection on one settings merge path. */
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Button } from '../ui/button'
|
||||
@@ -18,6 +17,7 @@ import { BellRing, Bot, FileAudio, Siren, Upload, Volume2 } from 'lucide-react'
|
||||
import { getNotificationSoundOptions } from '@/components/notification-sound-options'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { useAppStore } from '@/store'
|
||||
import { NotificationSettingToggle } from './NotificationSettingToggle'
|
||||
export { NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './notifications-search'
|
||||
|
||||
type NotificationsPaneProps = {
|
||||
@@ -265,7 +265,7 @@ export function NotificationsPane({
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<SettingToggle
|
||||
<NotificationSettingToggle
|
||||
label="Enable Notifications"
|
||||
description="Native system notifications for background events."
|
||||
checked={notificationSettings.enabled}
|
||||
@@ -279,7 +279,7 @@ export function NotificationsPane({
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingToggle
|
||||
<NotificationSettingToggle
|
||||
icon={<Bot className="size-4" />}
|
||||
label="Agent Task Complete"
|
||||
description="A coding agent finishes and becomes idle."
|
||||
@@ -292,7 +292,7 @@ export function NotificationsPane({
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
<NotificationSettingToggle
|
||||
icon={<Siren className="size-4" />}
|
||||
label="Terminal Bell"
|
||||
description="A background terminal emits a bell character."
|
||||
@@ -375,7 +375,7 @@ export function NotificationsPane({
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingToggle
|
||||
<NotificationSettingToggle
|
||||
label="Suppress While Focused"
|
||||
description="Skip notifications when the triggering worktree is already visible."
|
||||
checked={notificationSettings.suppressWhenFocused}
|
||||
@@ -402,49 +402,3 @@ export function NotificationsPane({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type SettingToggleProps = {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onToggle: () => void
|
||||
disabled?: boolean
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export function SettingToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
icon
|
||||
}: SettingToggleProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<Label>{label}</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
|
||||
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
checked ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
|
||||
export function GeneratedUrlRow({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
copied,
|
||||
onCopy
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
value: string
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label>{label}</Label>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 bg-background/70 px-2 py-1.5">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onCopy}
|
||||
aria-label={`Copy ${label}`}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnavailableUrlRow({
|
||||
label,
|
||||
description
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label>{label}</Label>
|
||||
<div className="rounded-md border border-border/60 px-2 py-1.5">
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: the generated URL cache, grant list, and
|
||||
settings form stay together so revocation and cache invalidation remain
|
||||
auditable. */
|
||||
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
@@ -11,6 +8,7 @@ import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { GeneratedUrlRow, UnavailableUrlRow } from './RuntimePairingGeneratedUrlRows'
|
||||
import { RuntimeAccessGrantList } from './RuntimeAccessGrantList'
|
||||
|
||||
const LOOPBACK_ADDRESS = '127.0.0.1'
|
||||
@@ -31,58 +29,6 @@ const runtimePairingUrlCache: {
|
||||
runtimePairingDeviceId: null
|
||||
}
|
||||
|
||||
function GeneratedUrlRow({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
copied,
|
||||
onCopy
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
value: string
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label>{label}</Label>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 bg-background/70 px-2 py-1.5">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onCopy}
|
||||
aria-label={`Copy ${label}`}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UnavailableUrlRow({
|
||||
label,
|
||||
description
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label>{label}</Label>
|
||||
<div className="rounded-md border border-border/60 px-2 py-1.5">
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type RuntimePairingUrlGeneratorProps = {
|
||||
framed?: boolean
|
||||
showHeader?: boolean
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: terminal visual controls stay together under Appearance so
|
||||
search, previews, and the Ghostty import flow move as one user-facing surface. */
|
||||
import { useState } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import {
|
||||
@@ -13,9 +11,7 @@ import {
|
||||
fontFamilyHasKnownLigatures,
|
||||
resolveTerminalLigaturesEnabled
|
||||
} from '../../../../shared/terminal-ligatures'
|
||||
import { Minus, Plus } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import {
|
||||
FontAutocomplete,
|
||||
NumberField,
|
||||
@@ -40,6 +36,7 @@ import {
|
||||
import { DarkTerminalThemeSection, LightTerminalThemeSection } from './TerminalThemeSections'
|
||||
import { TerminalWindowSection } from './TerminalWindowSection'
|
||||
import { TerminalSettingsPreview } from './TerminalSettingsPreview'
|
||||
import { TerminalFontSizeSetting } from './TerminalFontSizeSetting'
|
||||
import { GhosttyImportModal } from './GhosttyImportModal'
|
||||
import type { UseGhosttyImportReturn } from './useGhosttyImport'
|
||||
import ghosttyIcon from '../../../../../resources/ghostty.svg'
|
||||
@@ -88,56 +85,7 @@ export function TerminalAppearanceSection({
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/40">
|
||||
<SearchableSetting
|
||||
title="Font Size"
|
||||
description="Default terminal font size for new panes and live updates."
|
||||
keywords={['terminal', 'typography', 'text size']}
|
||||
>
|
||||
<SettingsRow
|
||||
label="Font Size"
|
||||
description="Default terminal font size for new panes and live updates."
|
||||
control={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
const next = Math.max(10, settings.terminalFontSize - 1)
|
||||
updateSettings({ terminalFontSize: next })
|
||||
}}
|
||||
disabled={settings.terminalFontSize <= 10}
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={24}
|
||||
value={settings.terminalFontSize}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10)
|
||||
if (!Number.isNaN(value) && value >= 10 && value <= 24) {
|
||||
updateSettings({ terminalFontSize: value })
|
||||
}
|
||||
}}
|
||||
className="w-14 text-center tabular-nums"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
const next = Math.min(24, settings.terminalFontSize + 1)
|
||||
updateSettings({ terminalFontSize: next })
|
||||
}}
|
||||
disabled={settings.terminalFontSize >= 24}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">px</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
<TerminalFontSizeSetting settings={settings} updateSettings={updateSettings} />
|
||||
|
||||
<SearchableSetting
|
||||
title="Font Family"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Minus, Plus } from 'lucide-react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { SettingsRow } from './SettingsFormControls'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
|
||||
export function TerminalFontSizeSetting({
|
||||
settings,
|
||||
updateSettings
|
||||
}: {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<SearchableSetting
|
||||
title="Font Size"
|
||||
description="Default terminal font size for new panes and live updates."
|
||||
keywords={['terminal', 'typography', 'text size']}
|
||||
>
|
||||
<SettingsRow
|
||||
label="Font Size"
|
||||
description="Default terminal font size for new panes and live updates."
|
||||
control={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
const next = Math.max(10, settings.terminalFontSize - 1)
|
||||
updateSettings({ terminalFontSize: next })
|
||||
}}
|
||||
disabled={settings.terminalFontSize <= 10}
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={24}
|
||||
value={settings.terminalFontSize}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10)
|
||||
if (!Number.isNaN(value) && value >= 10 && value <= 24) {
|
||||
updateSettings({ terminalFontSize: value })
|
||||
}
|
||||
}}
|
||||
className="w-14 text-center tabular-nums"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
const next = Math.min(24, settings.terminalFontSize + 1)
|
||||
updateSettings({ terminalFontSize: next })
|
||||
}}
|
||||
disabled={settings.terminalFontSize >= 24}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">px</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { CloneStep } from './AddRepoSteps'
|
||||
import { RemoteStep } from './AddRepoRemoteStep'
|
||||
import { CreateStep } from './AddRepoCreateStep'
|
||||
import { SetupStep } from './AddRepoSetupStep'
|
||||
import { AddRepoLocalStartStep, AddRepoServerPathStartStep } from './AddRepoStartSteps'
|
||||
import { AddRepoNestedImportStep } from './AddRepoNestedImportStep'
|
||||
import type { AddRepoDialogStep } from './add-repo-dialog-types'
|
||||
import type { NestedRepoScanResult } from '../../../../shared/types'
|
||||
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
|
||||
|
||||
type AddRepoDialogStepContentProps = {
|
||||
step: AddRepoDialogStep
|
||||
isRuntimeEnvironmentActive: boolean
|
||||
repoCount: number
|
||||
isAdding: boolean
|
||||
addProjectBusyLabel: string | null
|
||||
nestedScanInProgress: boolean
|
||||
nestedScanId: string | null
|
||||
serverPath: string
|
||||
isAddingServerPath: boolean
|
||||
cloneUrl: string
|
||||
cloneDestination: string
|
||||
cloneError: string | null
|
||||
cloneProgress: { phase: string; percent: number } | null
|
||||
isCloning: boolean
|
||||
sshTargets: (SshTarget & { state?: SshConnectionState })[]
|
||||
selectedTargetId: string | null
|
||||
remotePath: string
|
||||
remoteError: string | null
|
||||
isAddingRemote: boolean
|
||||
isScanningRemoteNested: boolean
|
||||
nestedScan: NestedRepoScanResult | null
|
||||
nestedSelectedPaths: Set<string>
|
||||
nestedGroupName: string
|
||||
createName: string
|
||||
createParent: string
|
||||
createKind: 'git' | 'folder'
|
||||
createError: string | null
|
||||
isCreating: boolean
|
||||
addedRepoName: string
|
||||
hiddenWorktreeCount: number
|
||||
primaryBranchName: string | null
|
||||
onBrowse: () => void
|
||||
onOpenCloneStep: () => void
|
||||
onOpenCreateStep: () => void
|
||||
onOpenRemoteStep: () => void
|
||||
onStopNestedScan: () => void
|
||||
onServerPathChange: (path: string) => void
|
||||
onAddServerPath: (kind: 'git' | 'folder') => void
|
||||
onSelectTarget: (id: string) => void
|
||||
onRemotePathChange: (path: string) => void
|
||||
onAddRemoteRepo: () => void
|
||||
onOpenSshSettings: () => void
|
||||
onConnectTarget: (id: string) => Promise<void>
|
||||
onStopRemoteNestedScan: () => void
|
||||
onCloneUrlChange: (url: string) => void
|
||||
onCloneDestinationChange: (destination: string) => void
|
||||
onPickCloneDestination: () => void
|
||||
onClone: () => void
|
||||
onNestedGroupNameChange: (name: string) => void
|
||||
onNestedSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
|
||||
onNestedBack: () => void
|
||||
onImportNestedRepos: (mode: 'group' | 'separate') => void
|
||||
onCreateNameChange: (name: string) => void
|
||||
onCreateParentChange: (parent: string) => void
|
||||
onCreateKindChange: (kind: 'git' | 'folder') => void
|
||||
onPickCreateParent: () => void
|
||||
onCreate: () => void
|
||||
onStartPrimaryWorktree: () => void
|
||||
onUseExistingWorktrees: () => void
|
||||
onCreateWorktree: (name?: string) => void
|
||||
onConfigureRepo: () => void
|
||||
}
|
||||
|
||||
export function AddRepoDialogStepContent({
|
||||
step,
|
||||
isRuntimeEnvironmentActive,
|
||||
repoCount,
|
||||
isAdding,
|
||||
addProjectBusyLabel,
|
||||
nestedScanInProgress,
|
||||
nestedScanId,
|
||||
serverPath,
|
||||
isAddingServerPath,
|
||||
cloneUrl,
|
||||
cloneDestination,
|
||||
cloneError,
|
||||
cloneProgress,
|
||||
isCloning,
|
||||
sshTargets,
|
||||
selectedTargetId,
|
||||
remotePath,
|
||||
remoteError,
|
||||
isAddingRemote,
|
||||
isScanningRemoteNested,
|
||||
nestedScan,
|
||||
nestedSelectedPaths,
|
||||
nestedGroupName,
|
||||
createName,
|
||||
createParent,
|
||||
createKind,
|
||||
createError,
|
||||
isCreating,
|
||||
addedRepoName,
|
||||
hiddenWorktreeCount,
|
||||
primaryBranchName,
|
||||
onBrowse,
|
||||
onOpenCloneStep,
|
||||
onOpenCreateStep,
|
||||
onOpenRemoteStep,
|
||||
onStopNestedScan,
|
||||
onServerPathChange,
|
||||
onAddServerPath,
|
||||
onSelectTarget,
|
||||
onRemotePathChange,
|
||||
onAddRemoteRepo,
|
||||
onOpenSshSettings,
|
||||
onConnectTarget,
|
||||
onStopRemoteNestedScan,
|
||||
onCloneUrlChange,
|
||||
onCloneDestinationChange,
|
||||
onPickCloneDestination,
|
||||
onClone,
|
||||
onNestedGroupNameChange,
|
||||
onNestedSelectedPathsChange,
|
||||
onNestedBack,
|
||||
onImportNestedRepos,
|
||||
onCreateNameChange,
|
||||
onCreateParentChange,
|
||||
onCreateKindChange,
|
||||
onPickCreateParent,
|
||||
onCreate,
|
||||
onStartPrimaryWorktree,
|
||||
onUseExistingWorktrees,
|
||||
onCreateWorktree,
|
||||
onConfigureRepo
|
||||
}: AddRepoDialogStepContentProps): React.JSX.Element {
|
||||
if (step === 'add' && isRuntimeEnvironmentActive) {
|
||||
return (
|
||||
<AddRepoServerPathStartStep
|
||||
serverPath={serverPath}
|
||||
isAddingServerPath={isAddingServerPath}
|
||||
addProjectBusyLabel={addProjectBusyLabel}
|
||||
onServerPathChange={onServerPathChange}
|
||||
onAddServerPath={onAddServerPath}
|
||||
onOpenCloneStep={onOpenCloneStep}
|
||||
onOpenCreateStep={onOpenCreateStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 'add') {
|
||||
return (
|
||||
<AddRepoLocalStartStep
|
||||
repoCount={repoCount}
|
||||
isAdding={isAdding}
|
||||
addProjectBusyLabel={addProjectBusyLabel}
|
||||
nestedScanInProgress={nestedScanInProgress}
|
||||
nestedScanId={nestedScanId}
|
||||
onBrowse={onBrowse}
|
||||
onOpenCloneStep={onOpenCloneStep}
|
||||
onOpenRemoteStep={onOpenRemoteStep}
|
||||
onOpenCreateStep={onOpenCreateStep}
|
||||
onStopNestedScan={onStopNestedScan}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 'remote') {
|
||||
return (
|
||||
<RemoteStep
|
||||
sshTargets={sshTargets}
|
||||
selectedTargetId={selectedTargetId}
|
||||
remotePath={remotePath}
|
||||
remoteError={remoteError}
|
||||
isAddingRemote={isAddingRemote}
|
||||
isScanningNested={isScanningRemoteNested}
|
||||
onSelectTarget={onSelectTarget}
|
||||
onRemotePathChange={onRemotePathChange}
|
||||
onAdd={onAddRemoteRepo}
|
||||
onOpenSshSettings={onOpenSshSettings}
|
||||
onConnectTarget={onConnectTarget}
|
||||
onStopNestedScan={onStopRemoteNestedScan}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 'clone') {
|
||||
return (
|
||||
<CloneStep
|
||||
cloneUrl={cloneUrl}
|
||||
cloneDestination={cloneDestination}
|
||||
cloneError={cloneError}
|
||||
cloneProgress={cloneProgress}
|
||||
isCloning={isCloning}
|
||||
disableDestinationPicker={isRuntimeEnvironmentActive}
|
||||
onUrlChange={onCloneUrlChange}
|
||||
onDestChange={onCloneDestinationChange}
|
||||
onPickDestination={onPickCloneDestination}
|
||||
onClone={onClone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 'nested' && nestedScan) {
|
||||
return (
|
||||
<AddRepoNestedImportStep
|
||||
scan={nestedScan}
|
||||
groupName={nestedGroupName}
|
||||
selectedPaths={nestedSelectedPaths}
|
||||
isAdding={isAdding}
|
||||
scanInProgress={nestedScanInProgress}
|
||||
onGroupNameChange={onNestedGroupNameChange}
|
||||
onSelectedPathsChange={onNestedSelectedPathsChange}
|
||||
onBack={onNestedBack}
|
||||
onImport={onImportNestedRepos}
|
||||
onStopScan={onStopNestedScan}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 'create') {
|
||||
return (
|
||||
<CreateStep
|
||||
createName={createName}
|
||||
createParent={createParent}
|
||||
createKind={createKind}
|
||||
createError={createError}
|
||||
isCreating={isCreating}
|
||||
manualParentEntry={isRuntimeEnvironmentActive}
|
||||
onNameChange={onCreateNameChange}
|
||||
onParentChange={onCreateParentChange}
|
||||
onKindChange={onCreateKindChange}
|
||||
onPickParent={onPickCreateParent}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupStep
|
||||
repoName={addedRepoName}
|
||||
hiddenWorktreeCount={hiddenWorktreeCount}
|
||||
primaryBranchName={primaryBranchName ?? undefined}
|
||||
onStartPrimaryWorktree={onStartPrimaryWorktree}
|
||||
onUseExistingWorktrees={onUseExistingWorktrees}
|
||||
onCreateWorktree={onCreateWorktree}
|
||||
onConfigureRepo={onConfigureRepo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import { ArrowLeft, CircleStop, FolderTree, Loader2 } from 'lucide-react'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { NestedRepoTreePreview } from '@/components/repo/NestedRepoTreePreview'
|
||||
import type { NestedRepoScanResult } from '../../../../shared/types'
|
||||
import { NestedRepoScanLimitNotice } from '../repo/NestedRepoScanLimitNotice'
|
||||
|
||||
type AddRepoNestedImportStepProps = {
|
||||
scan: NestedRepoScanResult
|
||||
groupName: string
|
||||
selectedPaths: Set<string>
|
||||
isAdding: boolean
|
||||
scanInProgress: boolean
|
||||
onGroupNameChange: (value: string) => void
|
||||
onSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
|
||||
onBack: () => void
|
||||
onImport: (mode: 'group' | 'separate') => void
|
||||
onStopScan: () => void
|
||||
}
|
||||
|
||||
export function AddRepoNestedImportStep({
|
||||
scan,
|
||||
groupName,
|
||||
selectedPaths,
|
||||
isAdding,
|
||||
scanInProgress,
|
||||
onGroupNameChange,
|
||||
onSelectedPathsChange,
|
||||
onBack,
|
||||
onImport,
|
||||
onStopScan
|
||||
}: AddRepoNestedImportStepProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import as project group</DialogTitle>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{scanInProgress ? <AddRepoNestedImportStopButton onStopScan={onStopScan} /> : null}
|
||||
<DialogDescription className="min-w-0 truncate">
|
||||
{`${scanInProgress ? 'Scanning... ' : ''}Found ${scan.repos.length} git ${
|
||||
scan.repos.length === 1 ? 'repository' : 'repositories'
|
||||
} in this folder.`}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 max-w-full flex-col gap-3 overflow-hidden pt-1">
|
||||
<div className="flex min-w-0 max-w-full items-center gap-3 overflow-hidden rounded-md border border-border bg-muted/30 p-3">
|
||||
<div className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
||||
<FolderTree className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
Group under {groupName}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{scan.selectedPath}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">Group name</label>
|
||||
<Input
|
||||
value={groupName}
|
||||
onChange={(event) => onGroupNameChange(event.target.value)}
|
||||
disabled={scanInProgress}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NestedRepoTreePreview
|
||||
scan={scan}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectedPathsChange={onSelectedPathsChange}
|
||||
disabled={isAdding || scanInProgress}
|
||||
className="flex-1"
|
||||
/>
|
||||
{scanInProgress || scan.truncated || scan.timedOut || scan.stopped ? (
|
||||
<NestedRepoScanLimitNotice scan={scan} />
|
||||
) : null}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button onClick={onBack} disabled={isAdding && !scanInProgress} variant="ghost">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="ml-auto flex min-w-0 flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => onImport('separate')}
|
||||
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
|
||||
variant="outline"
|
||||
>
|
||||
Import separately
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onImport('group')}
|
||||
disabled={isAdding || scanInProgress || selectedPaths.size === 0 || !groupName.trim()}
|
||||
>
|
||||
Import as project group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AddRepoNestedImportStopButton({
|
||||
onStopScan
|
||||
}: {
|
||||
onStopScan: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40"
|
||||
aria-label="Stop scan"
|
||||
title="Stop scanning"
|
||||
onClick={onStopScan}
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" />
|
||||
<CircleStop className="hidden size-3.5 group-hover:block group-focus-visible:block" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Scanning repositories. Click to stop.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react'
|
||||
import { CircleStop, FolderOpen, Settings } from 'lucide-react'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
|
||||
import { RemoteFileBrowser } from './RemoteFileBrowser'
|
||||
import { SshTargetRow } from './SshTargetRow'
|
||||
|
||||
type RemoteStepProps = {
|
||||
sshTargets: (SshTarget & { state?: SshConnectionState })[]
|
||||
selectedTargetId: string | null
|
||||
remotePath: string
|
||||
remoteError: string | null
|
||||
isAddingRemote: boolean
|
||||
isScanningNested?: boolean
|
||||
onSelectTarget: (id: string) => void
|
||||
onRemotePathChange: (value: string) => void
|
||||
onAdd: () => void
|
||||
onOpenSshSettings: () => void
|
||||
onConnectTarget: (id: string) => Promise<void>
|
||||
onStopNestedScan?: () => void
|
||||
}
|
||||
|
||||
export function RemoteStep({
|
||||
sshTargets,
|
||||
selectedTargetId,
|
||||
remotePath,
|
||||
remoteError,
|
||||
isAddingRemote,
|
||||
isScanningNested,
|
||||
onSelectTarget,
|
||||
onRemotePathChange,
|
||||
onAdd,
|
||||
onOpenSshSettings,
|
||||
onConnectTarget,
|
||||
onStopNestedScan
|
||||
}: RemoteStepProps): React.JSX.Element {
|
||||
const [browsing, setBrowsing] = useState(false)
|
||||
|
||||
if (browsing && selectedTargetId) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Browse remote filesystem</DialogTitle>
|
||||
<DialogDescription>
|
||||
Navigate to a directory and click Select to choose it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RemoteFileBrowser
|
||||
targetId={selectedTargetId}
|
||||
initialPath={remotePath || '~'}
|
||||
onSelect={(path) => {
|
||||
onRemotePathChange(path)
|
||||
setBrowsing(false)
|
||||
}}
|
||||
onCancel={() => setBrowsing(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Open remote project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a connected SSH target and enter the path to a Git repository.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">SSH target</label>
|
||||
{sshTargets.length === 0 ? (
|
||||
<div className="space-y-1.5 py-1">
|
||||
<p className="text-xs text-muted-foreground">No SSH targets configured.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={onOpenSshSettings}
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
Add in Settings
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek">
|
||||
{sshTargets.map((target) => (
|
||||
<SshTargetRow
|
||||
key={target.id}
|
||||
target={target}
|
||||
isSelected={selectedTargetId === target.id}
|
||||
onSelect={onSelectTarget}
|
||||
onConnect={onConnectTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">Remote path</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={remotePath}
|
||||
onChange={(event) => onRemotePathChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
if (selectedTargetId && remotePath.trim() && !isAddingRemote) {
|
||||
onAdd()
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="/home/user/project"
|
||||
className="h-8 text-xs flex-1"
|
||||
disabled={isAddingRemote || !selectedTargetId}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 shrink-0"
|
||||
onClick={() => setBrowsing(true)}
|
||||
disabled={!selectedTargetId || isAddingRemote}
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remoteError ? <p className="text-[11px] text-destructive">{remoteError}</p> : null}
|
||||
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
disabled={!selectedTargetId || !remotePath.trim() || isAddingRemote}
|
||||
className="w-full"
|
||||
>
|
||||
{isAddingRemote ? 'Adding...' : 'Add remote project'}
|
||||
</Button>
|
||||
{isScanningNested ? (
|
||||
<Button variant="outline" className="w-full" onClick={onStopNestedScan}>
|
||||
<CircleStop className="size-3.5" />
|
||||
Stop scan
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { CircleStop, FolderOpen, Globe, Lightbulb, Loader2, Monitor } from 'lucide-react'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
type AddRepoNestedScanProgressNoticeProps = {
|
||||
busyLabel: string
|
||||
nestedScanInProgress: boolean
|
||||
nestedScanId: string | null
|
||||
onStopNestedScan: () => void
|
||||
}
|
||||
|
||||
function AddRepoNestedScanProgressNotice({
|
||||
busyLabel,
|
||||
nestedScanInProgress,
|
||||
nestedScanId,
|
||||
onStopNestedScan
|
||||
}: AddRepoNestedScanProgressNoticeProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
<span className="min-w-0 flex-1">{busyLabel}</span>
|
||||
{nestedScanInProgress && nestedScanId ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="group text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:ring-destructive/40"
|
||||
aria-label="Stop scan"
|
||||
title="Stop scanning"
|
||||
onClick={onStopNestedScan}
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin text-annotation-highlight group-hover:hidden group-focus-visible:hidden" />
|
||||
<CircleStop className="hidden size-3.5 group-hover:block group-focus-visible:block" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Scanning repositories. Click to stop.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AddRepoServerPathStartStepProps = {
|
||||
serverPath: string
|
||||
isAddingServerPath: boolean
|
||||
addProjectBusyLabel: string | null
|
||||
onServerPathChange: (path: string) => void
|
||||
onAddServerPath: (kind: 'git' | 'folder') => void
|
||||
onOpenCloneStep: () => void
|
||||
onOpenCreateStep: () => void
|
||||
}
|
||||
|
||||
export function AddRepoServerPathStartStep({
|
||||
serverPath,
|
||||
isAddingServerPath,
|
||||
addProjectBusyLabel,
|
||||
onServerPathChange,
|
||||
onAddServerPath,
|
||||
onOpenCloneStep,
|
||||
onOpenCreateStep
|
||||
}: AddRepoServerPathStartStepProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a server project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a Git repository or folder that already exists on the selected runtime server.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="server-project-path"
|
||||
className="text-[11px] font-medium text-muted-foreground block"
|
||||
>
|
||||
Server path
|
||||
</label>
|
||||
<Input
|
||||
id="server-project-path"
|
||||
value={serverPath}
|
||||
onChange={(event) => onServerPathChange(event.target.value)}
|
||||
placeholder="/home/user/project"
|
||||
className="h-11 text-sm font-mono"
|
||||
disabled={isAddingServerPath}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={() => onAddServerPath('git')}
|
||||
disabled={!serverPath.trim() || isAddingServerPath}
|
||||
className="h-10"
|
||||
>
|
||||
Add Git Project
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onAddServerPath('folder')}
|
||||
disabled={!serverPath.trim() || isAddingServerPath}
|
||||
variant="outline"
|
||||
className="h-10"
|
||||
>
|
||||
Open as Folder
|
||||
</Button>
|
||||
</div>
|
||||
{isAddingServerPath && addProjectBusyLabel ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
<span>{addProjectBusyLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-center gap-4 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCloneStep}
|
||||
disabled={isAddingServerPath}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
Clone into server path
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateStep}
|
||||
disabled={isAddingServerPath}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
Create on server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AddRepoLocalStartStepProps = {
|
||||
repoCount: number
|
||||
isAdding: boolean
|
||||
addProjectBusyLabel: string | null
|
||||
nestedScanInProgress: boolean
|
||||
nestedScanId: string | null
|
||||
onBrowse: () => void
|
||||
onOpenCloneStep: () => void
|
||||
onOpenRemoteStep: () => void
|
||||
onOpenCreateStep: () => void
|
||||
onStopNestedScan: () => void
|
||||
}
|
||||
|
||||
export function AddRepoLocalStartStep({
|
||||
repoCount,
|
||||
isAdding,
|
||||
addProjectBusyLabel,
|
||||
nestedScanInProgress,
|
||||
nestedScanId,
|
||||
onBrowse,
|
||||
onOpenCloneStep,
|
||||
onOpenRemoteStep,
|
||||
onOpenCreateStep,
|
||||
onStopNestedScan
|
||||
}: AddRepoLocalStartStepProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a project</DialogTitle>
|
||||
<DialogDescription>
|
||||
{repoCount === 0
|
||||
? 'Add a project to get started with Orca.'
|
||||
: 'Add another project to manage with Orca.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBrowse}
|
||||
disabled={isAdding}
|
||||
variant="outline"
|
||||
className="h-auto py-5 px-2 flex flex-col items-center gap-2 text-center border-border/80"
|
||||
>
|
||||
<FolderOpen className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Browse folder</p>
|
||||
<p className="text-[11px] text-muted-foreground font-normal mt-0.5">
|
||||
Local Git project or folder
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onOpenCloneStep}
|
||||
disabled={isAdding}
|
||||
variant="outline"
|
||||
className="h-auto py-5 px-2 flex flex-col items-center gap-2 text-center border-border/80"
|
||||
>
|
||||
<Globe className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Clone from URL</p>
|
||||
<p className="text-[11px] text-muted-foreground font-normal mt-0.5">
|
||||
Remote Git repository
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onOpenRemoteStep}
|
||||
disabled={isAdding}
|
||||
variant="outline"
|
||||
className="h-auto py-5 px-2 flex flex-col items-center gap-2 text-center border-border/80"
|
||||
>
|
||||
<Monitor className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Remote project</p>
|
||||
<p className="text-[11px] text-muted-foreground font-normal mt-0.5">
|
||||
SSH connected target
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isAdding && addProjectBusyLabel ? (
|
||||
<AddRepoNestedScanProgressNotice
|
||||
busyLabel={addProjectBusyLabel}
|
||||
nestedScanInProgress={nestedScanInProgress}
|
||||
nestedScanId={nestedScanId}
|
||||
onStopNestedScan={onStopNestedScan}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-md border border-border bg-background text-foreground">
|
||||
<Lightbulb className="size-3.5" />
|
||||
</span>
|
||||
<span>Want to import many repos at once? Select the parent folder.</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateStep}
|
||||
disabled={isAdding}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
Or start a new project from scratch
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import type { AddRepoDialogStep } from './add-repo-dialog-types'
|
||||
|
||||
type AddRepoStepIndicatorProps = {
|
||||
step: AddRepoDialogStep
|
||||
isInputStep: boolean
|
||||
isAdding: boolean
|
||||
onBack: () => void
|
||||
onSetupBack: () => void
|
||||
}
|
||||
|
||||
export function AddRepoStepIndicator({
|
||||
step,
|
||||
isInputStep,
|
||||
isAdding,
|
||||
onBack,
|
||||
onSetupBack
|
||||
}: AddRepoStepIndicatorProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-center -mt-1">
|
||||
{(step === 'clone' || step === 'remote' || step === 'create') && (
|
||||
<button
|
||||
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{step === 'nested' && (
|
||||
<button
|
||||
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:cursor-default disabled:opacity-40"
|
||||
disabled={isAdding}
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{step === 'setup' && (
|
||||
<button
|
||||
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={onSetupBack}
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Add another project
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={`size-1.5 rounded-full transition-colors ${isInputStep ? 'bg-foreground' : 'bg-muted-foreground/30'}`}
|
||||
/>
|
||||
<div
|
||||
className={`size-1.5 rounded-full transition-colors ${step === 'setup' ? 'bg-foreground' : 'bg-muted-foreground/30'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
/* eslint-disable max-lines -- Why: AddRepoDialog step views are already split from the parent,
|
||||
and keeping clone/remote/setup step props together avoids a larger wizard refactor in this
|
||||
leak fix. */
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { CircleStop, Folder, FolderOpen, Settings } from 'lucide-react'
|
||||
import { Folder } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { RemoteFileBrowser } from './RemoteFileBrowser'
|
||||
import { SshTargetRow } from './SshTargetRow'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
|
||||
@@ -253,151 +248,6 @@ export function useRemoteRepo(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remote step ──────────────────────────────────────────────────────
|
||||
|
||||
type RemoteStepProps = {
|
||||
sshTargets: (SshTarget & { state?: SshConnectionState })[]
|
||||
selectedTargetId: string | null
|
||||
remotePath: string
|
||||
remoteError: string | null
|
||||
isAddingRemote: boolean
|
||||
isScanningNested?: boolean
|
||||
onSelectTarget: (id: string) => void
|
||||
onRemotePathChange: (value: string) => void
|
||||
onAdd: () => void
|
||||
onOpenSshSettings: () => void
|
||||
onConnectTarget: (id: string) => Promise<void>
|
||||
onStopNestedScan?: () => void
|
||||
}
|
||||
|
||||
export function RemoteStep({
|
||||
sshTargets,
|
||||
selectedTargetId,
|
||||
remotePath,
|
||||
remoteError,
|
||||
isAddingRemote,
|
||||
isScanningNested,
|
||||
onSelectTarget,
|
||||
onRemotePathChange,
|
||||
onAdd,
|
||||
onOpenSshSettings,
|
||||
onConnectTarget,
|
||||
onStopNestedScan
|
||||
}: RemoteStepProps): React.JSX.Element {
|
||||
const [browsing, setBrowsing] = useState(false)
|
||||
|
||||
if (browsing && selectedTargetId) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Browse remote filesystem</DialogTitle>
|
||||
<DialogDescription>
|
||||
Navigate to a directory and click Select to choose it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RemoteFileBrowser
|
||||
targetId={selectedTargetId}
|
||||
initialPath={remotePath || '~'}
|
||||
onSelect={(path) => {
|
||||
onRemotePathChange(path)
|
||||
setBrowsing(false)
|
||||
}}
|
||||
onCancel={() => setBrowsing(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Open remote project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a connected SSH target and enter the path to a Git repository.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">SSH target</label>
|
||||
{sshTargets.length === 0 ? (
|
||||
<div className="space-y-1.5 py-1">
|
||||
<p className="text-xs text-muted-foreground">No SSH targets configured.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={onOpenSshSettings}
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
Add in Settings
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1 scrollbar-sleek">
|
||||
{sshTargets.map((target) => (
|
||||
<SshTargetRow
|
||||
key={target.id}
|
||||
target={target}
|
||||
isSelected={selectedTargetId === target.id}
|
||||
onSelect={onSelectTarget}
|
||||
onConnect={onConnectTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">Remote path</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={remotePath}
|
||||
onChange={(e) => onRemotePathChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
if (selectedTargetId && remotePath.trim() && !isAddingRemote) {
|
||||
onAdd()
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="/home/user/project"
|
||||
className="h-8 text-xs flex-1"
|
||||
disabled={isAddingRemote || !selectedTargetId}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 shrink-0"
|
||||
onClick={() => setBrowsing(true)}
|
||||
disabled={!selectedTargetId || isAddingRemote}
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remoteError && <p className="text-[11px] text-destructive">{remoteError}</p>}
|
||||
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
disabled={!selectedTargetId || !remotePath.trim() || isAddingRemote}
|
||||
className="w-full"
|
||||
>
|
||||
{isAddingRemote ? 'Adding...' : 'Add remote project'}
|
||||
</Button>
|
||||
{isScanningNested ? (
|
||||
<Button variant="outline" className="w-full" onClick={onStopNestedScan}>
|
||||
<CircleStop className="size-3.5" />
|
||||
Stop scan
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Clone step ───────────────────────────────────────────────────────
|
||||
|
||||
type CloneStepProps = {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: the card metadata hover keeps compact badge rendering,
|
||||
provider-specific action rows, and markdown note preview together so the sidebar
|
||||
card has one metadata contract. */
|
||||
import React from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import {
|
||||
CircleDot,
|
||||
ExternalLink,
|
||||
@@ -25,6 +20,7 @@ import {
|
||||
WorktreeCardDetailSection,
|
||||
WorktreeCardDetailSectionContent
|
||||
} from './WorktreeCardDetailSection'
|
||||
import { DetailHeader, MetaIconBadge, MetadataActionIcon } from './WorktreeCardMetadataControls'
|
||||
import {
|
||||
IssueStateBadge,
|
||||
LinearStateBadge,
|
||||
@@ -145,90 +141,6 @@ function ReviewIcon({
|
||||
)
|
||||
}
|
||||
|
||||
function MetaIconBadge({
|
||||
label,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span className="inline-flex size-3.5 shrink-0 items-center justify-center text-muted-foreground/70 hover:text-foreground [&>svg]:size-3.5">
|
||||
{children}
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailHeader({
|
||||
icon,
|
||||
label,
|
||||
actions
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
actions?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-0.5">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetadataActionIcon({
|
||||
label,
|
||||
href,
|
||||
onClick,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
href?: string
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const trigger = href ? (
|
||||
<Button asChild variant="ghost" size="icon-xs" className="size-6">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={label}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-6"
|
||||
aria-label={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClick?.(event)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export const WorktreeCardMetaBadges = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
WorktreeCardMetaBadgesRootProps
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
export function MetaIconBadge({
|
||||
label,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span className="inline-flex size-3.5 shrink-0 items-center justify-center text-muted-foreground/70 hover:text-foreground [&>svg]:size-3.5">
|
||||
{children}
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailHeader({
|
||||
icon,
|
||||
label,
|
||||
actions
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
actions?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-0.5">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetadataActionIcon({
|
||||
label,
|
||||
href,
|
||||
onClick,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
href?: string
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const trigger = href ? (
|
||||
<Button asChild variant="ghost" size="icon-xs" className="size-6">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={label}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-6"
|
||||
aria-label={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClick?.(event)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type AddRepoDialogStep = 'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup'
|
||||
|
||||
export function defaultProjectGroupNameForPath(path: string): string {
|
||||
return (
|
||||
path
|
||||
.replace(/[\\/]+$/g, '')
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean)
|
||||
.at(-1) ?? path
|
||||
)
|
||||
}
|
||||
|
||||
export function createNestedRepoScanId(): string {
|
||||
return `nested-repo-scan-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { getCloneDestinationAutoFill } from './clone-defaults'
|
||||
import type { AddRepoDialogStep } from './add-repo-dialog-types'
|
||||
|
||||
export function useAddRepoCloneFlow({
|
||||
step,
|
||||
activeRuntimeEnvironmentId,
|
||||
workspaceDir,
|
||||
fetchWorktrees,
|
||||
setStep,
|
||||
setAddedRepo,
|
||||
setExistingWorkspaceSource
|
||||
}: {
|
||||
step: AddRepoDialogStep
|
||||
activeRuntimeEnvironmentId: string | null | undefined
|
||||
workspaceDir: string | null | undefined
|
||||
fetchWorktrees: (repoId: string) => Promise<unknown>
|
||||
setStep: (step: AddRepoDialogStep) => void
|
||||
setAddedRepo: (repo: Repo | null) => void
|
||||
setExistingWorkspaceSource: (source: AddRepoExistingWorkspaceSource | null) => void
|
||||
}): {
|
||||
cloneUrl: string
|
||||
cloneDestination: string
|
||||
cloneError: string | null
|
||||
cloneProgress: { phase: string; percent: number } | null
|
||||
isCloning: boolean
|
||||
setCloneUrl: Dispatch<SetStateAction<string>>
|
||||
setCloneDestination: Dispatch<SetStateAction<string>>
|
||||
setCloneError: Dispatch<SetStateAction<string | null>>
|
||||
resetCloneFlow: () => void
|
||||
handlePickDestination: () => Promise<void>
|
||||
handleClone: () => Promise<void>
|
||||
} {
|
||||
const [cloneUrl, setCloneUrl] = useState('')
|
||||
const [cloneDestination, setCloneDestination] = useState('')
|
||||
const [isCloning, setIsCloning] = useState(false)
|
||||
const [cloneError, setCloneError] = useState<string | null>(null)
|
||||
const [cloneProgress, setCloneProgress] = useState<{ phase: string; percent: number } | null>(
|
||||
null
|
||||
)
|
||||
// Why: monotonic ID so stale clone callbacks can detect they were superseded.
|
||||
const cloneGenRef = useRef(0)
|
||||
// Why: track whether we've already auto-filled for this entry into the clone step,
|
||||
// so a late settings hydration still gets a chance to set the default.
|
||||
const cloneStepAutoFilledRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCloning) {
|
||||
return
|
||||
}
|
||||
return window.api.repos.onCloneProgress(setCloneProgress)
|
||||
}, [isCloning])
|
||||
|
||||
const cloneDestinationAutoFill = getCloneDestinationAutoFill({
|
||||
step,
|
||||
cloneDestination,
|
||||
activeRuntimeEnvironmentId,
|
||||
workspaceDir,
|
||||
cloneStepAutoFilled: cloneStepAutoFilledRef.current
|
||||
})
|
||||
if (step !== 'clone') {
|
||||
cloneStepAutoFilledRef.current = false
|
||||
} else if (cloneDestinationAutoFill) {
|
||||
// Why: late settings hydration should still seed the local clone path,
|
||||
// but runtime/server clone flows must keep their destination user-entered.
|
||||
cloneStepAutoFilledRef.current = true
|
||||
setCloneDestination(cloneDestinationAutoFill.destination)
|
||||
}
|
||||
|
||||
const resetCloneFlow = useCallback((): void => {
|
||||
cloneGenRef.current++
|
||||
setCloneUrl('')
|
||||
setCloneDestination('')
|
||||
setIsCloning(false)
|
||||
setCloneError(null)
|
||||
setCloneProgress(null)
|
||||
}, [])
|
||||
|
||||
const handlePickDestination = useCallback(async (): Promise<void> => {
|
||||
if (activeRuntimeEnvironmentId?.trim()) {
|
||||
// Why: the native folder picker returns a client-local path. Runtime
|
||||
// clone destinations must be typed as server paths.
|
||||
toast.error('Enter a server path for the clone destination.')
|
||||
return
|
||||
}
|
||||
const gen = cloneGenRef.current
|
||||
const dir = await window.api.repos.pickDirectory()
|
||||
if (dir && gen === cloneGenRef.current) {
|
||||
setCloneDestination(dir)
|
||||
setCloneError(null)
|
||||
}
|
||||
}, [activeRuntimeEnvironmentId])
|
||||
|
||||
const handleClone = useCallback(async (): Promise<void> => {
|
||||
const trimmedUrl = cloneUrl.trim()
|
||||
if (!trimmedUrl || !cloneDestination.trim()) {
|
||||
return
|
||||
}
|
||||
const gen = ++cloneGenRef.current
|
||||
setIsCloning(true)
|
||||
setCloneError(null)
|
||||
setCloneProgress(null)
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(useAppStore.getState().settings)
|
||||
const repo =
|
||||
target.kind === 'environment'
|
||||
? (
|
||||
await callRuntimeRpc<{ repo: Repo }>(
|
||||
target,
|
||||
'repo.clone',
|
||||
{
|
||||
url: trimmedUrl,
|
||||
destination: cloneDestination.trim()
|
||||
},
|
||||
{ timeoutMs: 10 * 60_000 }
|
||||
)
|
||||
).repo
|
||||
: ((await window.api.repos.clone({
|
||||
url: trimmedUrl,
|
||||
destination: cloneDestination.trim()
|
||||
})) as Repo)
|
||||
if (gen !== cloneGenRef.current) {
|
||||
return
|
||||
}
|
||||
toast.success('Repository cloned', { description: repo.displayName })
|
||||
// Why: eagerly upsert so step 2 finds the repo before the IPC event.
|
||||
const state = useAppStore.getState()
|
||||
const existingIdx = state.repos.findIndex((r) => r.id === repo.id)
|
||||
if (existingIdx === -1) {
|
||||
useAppStore.setState({ repos: [...state.repos, repo] })
|
||||
} else {
|
||||
const updated = [...state.repos]
|
||||
updated[existingIdx] = repo
|
||||
useAppStore.setState({ repos: updated })
|
||||
}
|
||||
setAddedRepo(repo)
|
||||
setExistingWorkspaceSource('clone_url')
|
||||
await fetchWorktrees(repo.id)
|
||||
if (gen !== cloneGenRef.current) {
|
||||
return
|
||||
}
|
||||
setStep('setup')
|
||||
} catch (err) {
|
||||
if (gen !== cloneGenRef.current) {
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setCloneError(message)
|
||||
} finally {
|
||||
if (gen === cloneGenRef.current) {
|
||||
setIsCloning(false)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
cloneUrl,
|
||||
cloneDestination,
|
||||
fetchWorktrees,
|
||||
setAddedRepo,
|
||||
setExistingWorkspaceSource,
|
||||
setStep
|
||||
])
|
||||
|
||||
return {
|
||||
cloneUrl,
|
||||
cloneDestination,
|
||||
cloneError,
|
||||
cloneProgress,
|
||||
isCloning,
|
||||
setCloneUrl,
|
||||
setCloneDestination,
|
||||
setCloneError,
|
||||
resetCloneFlow,
|
||||
handlePickDestination,
|
||||
handleClone
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
buildNestedRepoScanTelemetry,
|
||||
createNestedRepoTelemetryAttemptId,
|
||||
type NestedRepoTelemetryRuntimeKind
|
||||
} from '../../../../shared/nested-repo-telemetry'
|
||||
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
|
||||
import { createNestedRepoScanId, type AddRepoDialogStep } from './add-repo-dialog-types'
|
||||
|
||||
type ShowNestedRepoReview = (args: {
|
||||
scan: NestedRepoScanResult
|
||||
selectedPath: string
|
||||
connectionId: string | null
|
||||
attemptId: string
|
||||
runtimeKind: NestedRepoTelemetryRuntimeKind
|
||||
inProgress: boolean
|
||||
scanId: string | null
|
||||
}) => void
|
||||
|
||||
export function useAddRepoLocalFolderFlow({
|
||||
isOpen,
|
||||
droppedLocalPath,
|
||||
activeRuntimeEnvironmentId,
|
||||
addRepoPath,
|
||||
closeModal,
|
||||
fetchWorktrees,
|
||||
scanNestedRepos,
|
||||
setActiveNestedScanId,
|
||||
setNestedScanInProgress,
|
||||
showNestedRepoReview,
|
||||
setStep,
|
||||
setAddedRepo,
|
||||
setExistingWorkspaceSource,
|
||||
setIsAdding,
|
||||
setAddProjectBusyLabel
|
||||
}: {
|
||||
isOpen: boolean
|
||||
droppedLocalPath: string
|
||||
activeRuntimeEnvironmentId: string | null | undefined
|
||||
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
|
||||
closeModal: () => void
|
||||
fetchWorktrees: (repoId: string) => Promise<unknown>
|
||||
scanNestedRepos: (
|
||||
path: string,
|
||||
connectionId?: string,
|
||||
controls?: { scanId?: string; onProgress?: (scan: NestedRepoScanResult) => void }
|
||||
) => Promise<NestedRepoScanResult | null>
|
||||
setActiveNestedScanId: (scanId: string | null) => void
|
||||
setNestedScanInProgress: (inProgress: boolean) => void
|
||||
showNestedRepoReview: ShowNestedRepoReview
|
||||
setStep: (step: AddRepoDialogStep) => void
|
||||
setAddedRepo: (repo: Repo | null) => void
|
||||
setExistingWorkspaceSource: (source: AddRepoExistingWorkspaceSource | null) => void
|
||||
setIsAdding: (isAdding: boolean) => void
|
||||
setAddProjectBusyLabel: (label: string | null) => void
|
||||
}): {
|
||||
handleBrowse: () => Promise<void>
|
||||
resetLocalFolderFlow: () => void
|
||||
} {
|
||||
const localAddGenRef = useRef(0)
|
||||
const droppedLocalPathHandledRef = useRef<string | null>(null)
|
||||
|
||||
const resetLocalFolderFlow = useCallback((): void => {
|
||||
localAddGenRef.current++
|
||||
droppedLocalPathHandledRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleAddLocalPath = useCallback(
|
||||
async (path: string, source: AddRepoExistingWorkspaceSource): Promise<void> => {
|
||||
if (activeRuntimeEnvironmentId?.trim()) {
|
||||
toast.error('Use a server path to add projects from a remote runtime.')
|
||||
closeModal()
|
||||
return
|
||||
}
|
||||
const gen = ++localAddGenRef.current
|
||||
setIsAdding(true)
|
||||
setAddProjectBusyLabel('Scanning for repositories...')
|
||||
try {
|
||||
const attemptId = createNestedRepoTelemetryAttemptId()
|
||||
const scanId = createNestedRepoScanId()
|
||||
setActiveNestedScanId(scanId)
|
||||
setNestedScanInProgress(true)
|
||||
const scan = await scanNestedRepos(path, undefined, {
|
||||
scanId,
|
||||
onProgress: (progressScan) => {
|
||||
if (
|
||||
gen !== localAddGenRef.current ||
|
||||
progressScan.selectedPathKind !== 'non_git_folder' ||
|
||||
progressScan.repos.length === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
showNestedRepoReview({
|
||||
scan: progressScan,
|
||||
selectedPath: path,
|
||||
connectionId: null,
|
||||
attemptId,
|
||||
runtimeKind: 'local',
|
||||
inProgress: true,
|
||||
scanId
|
||||
})
|
||||
}
|
||||
})
|
||||
if (gen !== localAddGenRef.current) {
|
||||
return
|
||||
}
|
||||
setNestedScanInProgress(false)
|
||||
setActiveNestedScanId(null)
|
||||
track(
|
||||
'add_repo_nested_scan_result',
|
||||
buildNestedRepoScanTelemetry({
|
||||
attemptId,
|
||||
surface: 'sidebar',
|
||||
runtimeKind: 'local',
|
||||
scan
|
||||
})
|
||||
)
|
||||
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
|
||||
showNestedRepoReview({
|
||||
scan,
|
||||
selectedPath: path,
|
||||
connectionId: null,
|
||||
attemptId,
|
||||
runtimeKind: 'local',
|
||||
inProgress: false,
|
||||
scanId
|
||||
})
|
||||
return
|
||||
}
|
||||
setAddProjectBusyLabel('Opening project...')
|
||||
const repo = await addRepoPath(path)
|
||||
if (gen !== localAddGenRef.current) {
|
||||
return
|
||||
}
|
||||
if (repo && isGitRepoKind(repo)) {
|
||||
setAddedRepo(repo)
|
||||
setExistingWorkspaceSource(source)
|
||||
await fetchWorktrees(repo.id)
|
||||
if (gen !== localAddGenRef.current) {
|
||||
return
|
||||
}
|
||||
setStep('setup')
|
||||
} else if (repo) {
|
||||
// Why: folder repos skip the Git worktree setup step and activate
|
||||
// their synthetic root workspace in the folder add flow.
|
||||
closeModal()
|
||||
}
|
||||
} finally {
|
||||
if (gen === localAddGenRef.current) {
|
||||
setNestedScanInProgress(false)
|
||||
setActiveNestedScanId(null)
|
||||
setIsAdding(false)
|
||||
setAddProjectBusyLabel(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeRuntimeEnvironmentId,
|
||||
addRepoPath,
|
||||
closeModal,
|
||||
fetchWorktrees,
|
||||
scanNestedRepos,
|
||||
setActiveNestedScanId,
|
||||
setAddProjectBusyLabel,
|
||||
setAddedRepo,
|
||||
setExistingWorkspaceSource,
|
||||
setIsAdding,
|
||||
setNestedScanInProgress,
|
||||
setStep,
|
||||
showNestedRepoReview
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !droppedLocalPath) {
|
||||
return
|
||||
}
|
||||
if (droppedLocalPathHandledRef.current === droppedLocalPath) {
|
||||
return
|
||||
}
|
||||
droppedLocalPathHandledRef.current = droppedLocalPath
|
||||
void handleAddLocalPath(droppedLocalPath, 'local_folder_picker')
|
||||
}, [droppedLocalPath, handleAddLocalPath, isOpen])
|
||||
|
||||
const handleBrowse = useCallback(async (): Promise<void> => {
|
||||
const gen = ++localAddGenRef.current
|
||||
setIsAdding(true)
|
||||
setAddProjectBusyLabel('Choose a folder...')
|
||||
try {
|
||||
const path = await window.api.repos.pickFolder()
|
||||
if (!path || gen !== localAddGenRef.current) {
|
||||
return
|
||||
}
|
||||
await handleAddLocalPath(path, 'local_folder_picker')
|
||||
} finally {
|
||||
if (gen === localAddGenRef.current) {
|
||||
setIsAdding(false)
|
||||
}
|
||||
}
|
||||
}, [handleAddLocalPath, setAddProjectBusyLabel, setIsAdding])
|
||||
|
||||
return { handleBrowse, resetLocalFolderFlow }
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
buildNestedRepoImportActionTelemetry,
|
||||
buildNestedRepoImportResultTelemetry,
|
||||
shouldEmitNestedRepoImportSubmitTelemetry,
|
||||
type NestedRepoTelemetryRuntimeKind
|
||||
} from '../../../../shared/nested-repo-telemetry'
|
||||
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import type { NestedRepoScanResult, ProjectGroupImportResult, Repo } from '../../../../shared/types'
|
||||
import type { AddRepoDialogStep } from './add-repo-dialog-types'
|
||||
|
||||
export function useAddRepoNestedImportFlow({
|
||||
nestedAttemptId,
|
||||
nestedScan,
|
||||
nestedSelectedPaths,
|
||||
nestedRuntimeKind,
|
||||
nestedConnectionId,
|
||||
nestedGroupName,
|
||||
nestedImportScanId,
|
||||
activeRuntimeEnvironmentId,
|
||||
fetchWorktrees,
|
||||
importNestedRepos,
|
||||
getNestedRepoRuntimeKind,
|
||||
setAddedRepo,
|
||||
setExistingWorkspaceSource,
|
||||
setStep,
|
||||
setIsAdding
|
||||
}: {
|
||||
nestedAttemptId: string | null
|
||||
nestedScan: NestedRepoScanResult | null
|
||||
nestedSelectedPaths: Set<string>
|
||||
nestedRuntimeKind: NestedRepoTelemetryRuntimeKind | null
|
||||
nestedConnectionId: string | null
|
||||
nestedGroupName: string
|
||||
nestedImportScanId: string | null
|
||||
activeRuntimeEnvironmentId: string | null | undefined
|
||||
fetchWorktrees: (repoId: string) => Promise<unknown>
|
||||
importNestedRepos: (args: {
|
||||
parentPath: string
|
||||
groupName: string
|
||||
projectPaths: string[]
|
||||
connectionId?: string
|
||||
scanId?: string
|
||||
mode: 'group' | 'separate'
|
||||
}) => Promise<ProjectGroupImportResult | null>
|
||||
getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind
|
||||
setAddedRepo: (repo: Repo | null) => void
|
||||
setExistingWorkspaceSource: (source: AddRepoExistingWorkspaceSource | null) => void
|
||||
setStep: (step: AddRepoDialogStep) => void
|
||||
setIsAdding: (isAdding: boolean) => void
|
||||
}): {
|
||||
handleImportNestedRepos: (mode: 'group' | 'separate') => Promise<void>
|
||||
resetNestedImportFlow: () => void
|
||||
trackNestedBackAction: () => void
|
||||
} {
|
||||
const nestedImportGenRef = useRef(0)
|
||||
|
||||
const resetNestedImportFlow = useCallback((): void => {
|
||||
nestedImportGenRef.current++
|
||||
}, [])
|
||||
|
||||
const trackNestedBackAction = useCallback((): void => {
|
||||
if (!nestedScan || !nestedAttemptId) {
|
||||
return
|
||||
}
|
||||
track(
|
||||
'add_repo_nested_import_action',
|
||||
buildNestedRepoImportActionTelemetry({
|
||||
attemptId: nestedAttemptId,
|
||||
surface: 'sidebar',
|
||||
runtimeKind: nestedRuntimeKind ?? getNestedRepoRuntimeKind(nestedConnectionId),
|
||||
action: 'back',
|
||||
foundCount: nestedScan.repos.length,
|
||||
selectedCount: nestedSelectedPaths.size
|
||||
})
|
||||
)
|
||||
}, [
|
||||
getNestedRepoRuntimeKind,
|
||||
nestedAttemptId,
|
||||
nestedConnectionId,
|
||||
nestedRuntimeKind,
|
||||
nestedScan,
|
||||
nestedSelectedPaths.size
|
||||
])
|
||||
|
||||
const handleImportNestedRepos = useCallback(
|
||||
async (mode: 'group' | 'separate'): Promise<void> => {
|
||||
const attemptId = nestedAttemptId
|
||||
if (
|
||||
!nestedScan ||
|
||||
!attemptId ||
|
||||
!shouldEmitNestedRepoImportSubmitTelemetry({
|
||||
attemptId,
|
||||
selectedCount: nestedSelectedPaths.size
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
const foundCount = nestedScan.repos.length
|
||||
const selectedCount = nestedSelectedPaths.size
|
||||
const runtimeKind = nestedRuntimeKind ?? getNestedRepoRuntimeKind(nestedConnectionId)
|
||||
const gen = ++nestedImportGenRef.current
|
||||
setIsAdding(true)
|
||||
track(
|
||||
'add_repo_nested_import_action',
|
||||
buildNestedRepoImportActionTelemetry({
|
||||
attemptId,
|
||||
surface: 'sidebar',
|
||||
runtimeKind,
|
||||
action: mode === 'group' ? 'import_group' : 'import_separate',
|
||||
foundCount,
|
||||
selectedCount
|
||||
})
|
||||
)
|
||||
let resultTracked = false
|
||||
try {
|
||||
const result = await importNestedRepos({
|
||||
parentPath: nestedScan.selectedPath,
|
||||
groupName: nestedGroupName,
|
||||
projectPaths: [...nestedSelectedPaths],
|
||||
...(nestedConnectionId ? { connectionId: nestedConnectionId } : {}),
|
||||
...(nestedImportScanId ? { scanId: nestedImportScanId } : {}),
|
||||
mode
|
||||
})
|
||||
track(
|
||||
'add_repo_nested_import_result',
|
||||
buildNestedRepoImportResultTelemetry({
|
||||
attemptId,
|
||||
surface: 'sidebar',
|
||||
runtimeKind,
|
||||
mode,
|
||||
foundCount,
|
||||
selectedCount,
|
||||
result
|
||||
})
|
||||
)
|
||||
resultTracked = true
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
const importedRepoIds = result.projects
|
||||
.map((entry) => entry.projectId)
|
||||
.filter((projectId): projectId is string => typeof projectId === 'string')
|
||||
const firstRepoId = importedRepoIds[0]
|
||||
if (!firstRepoId) {
|
||||
const firstFailure = result.projects.find((entry) => entry.status === 'failed')?.error
|
||||
if (gen === nestedImportGenRef.current) {
|
||||
toast.error('No repositories imported', {
|
||||
description: firstFailure ?? undefined
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const projectId of importedRepoIds) {
|
||||
await fetchWorktrees(projectId)
|
||||
}
|
||||
if (gen !== nestedImportGenRef.current) {
|
||||
return
|
||||
}
|
||||
const repo = useAppStore.getState().repos.find((entry) => entry.id === firstRepoId)
|
||||
if (repo) {
|
||||
setAddedRepo(repo)
|
||||
setExistingWorkspaceSource(
|
||||
nestedConnectionId
|
||||
? 'ssh_remote_path'
|
||||
: activeRuntimeEnvironmentId?.trim()
|
||||
? 'runtime_server_path'
|
||||
: 'local_folder_picker'
|
||||
)
|
||||
setStep('setup')
|
||||
}
|
||||
if (result.failedCount > 0 && gen === nestedImportGenRef.current) {
|
||||
toast.warning('Some repositories could not be imported', {
|
||||
description: `${result.failedCount} failed`
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (!resultTracked) {
|
||||
track(
|
||||
'add_repo_nested_import_result',
|
||||
buildNestedRepoImportResultTelemetry({
|
||||
attemptId,
|
||||
surface: 'sidebar',
|
||||
runtimeKind,
|
||||
mode,
|
||||
foundCount,
|
||||
selectedCount,
|
||||
result: null
|
||||
})
|
||||
)
|
||||
}
|
||||
if (gen === nestedImportGenRef.current) {
|
||||
setIsAdding(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeRuntimeEnvironmentId,
|
||||
fetchWorktrees,
|
||||
importNestedRepos,
|
||||
nestedAttemptId,
|
||||
nestedConnectionId,
|
||||
nestedGroupName,
|
||||
nestedImportScanId,
|
||||
nestedRuntimeKind,
|
||||
nestedScan,
|
||||
nestedSelectedPaths,
|
||||
getNestedRepoRuntimeKind,
|
||||
setAddedRepo,
|
||||
setExistingWorkspaceSource,
|
||||
setIsAdding,
|
||||
setStep
|
||||
]
|
||||
)
|
||||
|
||||
return { handleImportNestedRepos, resetNestedImportFlow, trackNestedBackAction }
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { NestedRepoTelemetryRuntimeKind } from '../../../../shared/nested-repo-telemetry'
|
||||
import type { NestedRepoScanResult } from '../../../../shared/types'
|
||||
import { defaultProjectGroupNameForPath, type AddRepoDialogStep } from './add-repo-dialog-types'
|
||||
|
||||
type ShowNestedRepoReviewArgs = {
|
||||
scan: NestedRepoScanResult
|
||||
selectedPath: string
|
||||
connectionId: string | null
|
||||
attemptId: string
|
||||
runtimeKind: NestedRepoTelemetryRuntimeKind
|
||||
inProgress: boolean
|
||||
scanId: string | null
|
||||
}
|
||||
|
||||
export function useAddRepoNestedReviewState({
|
||||
activeRuntimeEnvironmentId,
|
||||
cancelNestedRepoScan,
|
||||
setStep
|
||||
}: {
|
||||
activeRuntimeEnvironmentId: string | null | undefined
|
||||
cancelNestedRepoScan: (scanId: string) => Promise<unknown>
|
||||
setStep: (step: AddRepoDialogStep) => void
|
||||
}): {
|
||||
nestedScan: NestedRepoScanResult | null
|
||||
nestedSelectedPaths: Set<string>
|
||||
nestedGroupName: string
|
||||
nestedConnectionId: string | null
|
||||
nestedAttemptId: string | null
|
||||
nestedRuntimeKind: NestedRepoTelemetryRuntimeKind | null
|
||||
nestedScanInProgress: boolean
|
||||
nestedScanId: string | null
|
||||
nestedImportScanId: string | null
|
||||
setNestedSelectedPaths: Dispatch<SetStateAction<Set<string>>>
|
||||
setNestedGroupName: Dispatch<SetStateAction<string>>
|
||||
setNestedScanInProgress: Dispatch<SetStateAction<boolean>>
|
||||
getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind
|
||||
showNestedRepoReview: (args: ShowNestedRepoReviewArgs) => void
|
||||
setActiveNestedScanId: (scanId: string | null) => void
|
||||
handleStopNestedScan: () => void
|
||||
resetNestedRepoReviewState: () => void
|
||||
} {
|
||||
const [nestedScan, setNestedScan] = useState<NestedRepoScanResult | null>(null)
|
||||
const [nestedSelectedPaths, setNestedSelectedPaths] = useState<Set<string>>(new Set())
|
||||
const [nestedGroupName, setNestedGroupName] = useState('')
|
||||
const [nestedConnectionId, setNestedConnectionId] = useState<string | null>(null)
|
||||
const [nestedAttemptId, setNestedAttemptId] = useState<string | null>(null)
|
||||
const [nestedRuntimeKind, setNestedRuntimeKind] = useState<NestedRepoTelemetryRuntimeKind | null>(
|
||||
null
|
||||
)
|
||||
const [nestedScanInProgress, setNestedScanInProgress] = useState(false)
|
||||
const [nestedScanId, setNestedScanId] = useState<string | null>(null)
|
||||
const [nestedImportScanId, setNestedImportScanId] = useState<string | null>(null)
|
||||
const nestedScanIdRef = useRef<string | null>(null)
|
||||
|
||||
const getNestedRepoRuntimeKind = useCallback(
|
||||
(connectionId: string | null): NestedRepoTelemetryRuntimeKind => {
|
||||
if (connectionId) {
|
||||
return 'ssh'
|
||||
}
|
||||
return activeRuntimeEnvironmentId?.trim() ? 'runtime' : 'local'
|
||||
},
|
||||
[activeRuntimeEnvironmentId]
|
||||
)
|
||||
|
||||
const showNestedRepoReview = useCallback(
|
||||
(args: ShowNestedRepoReviewArgs): void => {
|
||||
setNestedScan(args.scan)
|
||||
setNestedSelectedPaths(new Set(args.scan.repos.map((repo) => repo.path)))
|
||||
setNestedGroupName(
|
||||
defaultProjectGroupNameForPath(args.scan.selectedPath || args.selectedPath)
|
||||
)
|
||||
setNestedConnectionId(args.connectionId)
|
||||
setNestedAttemptId(args.attemptId)
|
||||
setNestedRuntimeKind(args.runtimeKind)
|
||||
setNestedScanInProgress(args.inProgress)
|
||||
setNestedImportScanId(args.scanId)
|
||||
setStep('nested')
|
||||
},
|
||||
[setStep]
|
||||
)
|
||||
|
||||
const setActiveNestedScanId = useCallback((scanId: string | null): void => {
|
||||
nestedScanIdRef.current = scanId
|
||||
setNestedScanId(scanId)
|
||||
}, [])
|
||||
|
||||
const handleStopNestedScan = useCallback(() => {
|
||||
const scanId = nestedScanIdRef.current
|
||||
if (!scanId) {
|
||||
return
|
||||
}
|
||||
void cancelNestedRepoScan(scanId)
|
||||
}, [cancelNestedRepoScan])
|
||||
|
||||
const resetNestedRepoReviewState = useCallback((): void => {
|
||||
const activeNestedScanId = nestedScanIdRef.current
|
||||
if (activeNestedScanId) {
|
||||
void cancelNestedRepoScan(activeNestedScanId)
|
||||
}
|
||||
setNestedScan(null)
|
||||
setNestedSelectedPaths(new Set())
|
||||
setNestedGroupName('')
|
||||
setNestedConnectionId(null)
|
||||
setNestedAttemptId(null)
|
||||
setNestedRuntimeKind(null)
|
||||
setNestedScanInProgress(false)
|
||||
setNestedImportScanId(null)
|
||||
setActiveNestedScanId(null)
|
||||
}, [cancelNestedRepoScan, setActiveNestedScanId])
|
||||
|
||||
return {
|
||||
nestedScan,
|
||||
nestedSelectedPaths,
|
||||
nestedGroupName,
|
||||
nestedConnectionId,
|
||||
nestedAttemptId,
|
||||
nestedRuntimeKind,
|
||||
nestedScanInProgress,
|
||||
nestedScanId,
|
||||
nestedImportScanId,
|
||||
setNestedSelectedPaths,
|
||||
setNestedGroupName,
|
||||
setNestedScanInProgress,
|
||||
getNestedRepoRuntimeKind,
|
||||
showNestedRepoReview,
|
||||
setActiveNestedScanId,
|
||||
handleStopNestedScan,
|
||||
resetNestedRepoReviewState
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
buildNestedRepoScanTelemetry,
|
||||
createNestedRepoTelemetryAttemptId,
|
||||
type NestedRepoTelemetryRuntimeKind
|
||||
} from '../../../../shared/nested-repo-telemetry'
|
||||
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
|
||||
import { createNestedRepoScanId, type AddRepoDialogStep } from './add-repo-dialog-types'
|
||||
|
||||
type ShowNestedRepoReview = (args: {
|
||||
scan: NestedRepoScanResult
|
||||
selectedPath: string
|
||||
connectionId: string | null
|
||||
attemptId: string
|
||||
runtimeKind: NestedRepoTelemetryRuntimeKind
|
||||
inProgress: boolean
|
||||
scanId: string | null
|
||||
}) => void
|
||||
|
||||
export function useAddRepoServerPathFlow({
|
||||
addRepoPath,
|
||||
closeModal,
|
||||
fetchWorktrees,
|
||||
getNestedRepoRuntimeKind,
|
||||
scanNestedRepos,
|
||||
setActiveNestedScanId,
|
||||
setNestedScanInProgress,
|
||||
showNestedRepoReview,
|
||||
setStep,
|
||||
setAddedRepo,
|
||||
setExistingWorkspaceSource,
|
||||
setAddProjectBusyLabel
|
||||
}: {
|
||||
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
|
||||
closeModal: () => void
|
||||
fetchWorktrees: (repoId: string) => Promise<unknown>
|
||||
getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind
|
||||
scanNestedRepos: (
|
||||
path: string,
|
||||
connectionId?: string,
|
||||
controls?: { scanId?: string; onProgress?: (scan: NestedRepoScanResult) => void }
|
||||
) => Promise<NestedRepoScanResult | null>
|
||||
setActiveNestedScanId: (scanId: string | null) => void
|
||||
setNestedScanInProgress: (inProgress: boolean) => void
|
||||
showNestedRepoReview: ShowNestedRepoReview
|
||||
setStep: (step: AddRepoDialogStep) => void
|
||||
setAddedRepo: (repo: Repo | null) => void
|
||||
setExistingWorkspaceSource: (source: AddRepoExistingWorkspaceSource | null) => void
|
||||
setAddProjectBusyLabel: (label: string | null) => void
|
||||
}): {
|
||||
serverPath: string
|
||||
isAddingServerPath: boolean
|
||||
setServerPath: Dispatch<SetStateAction<string>>
|
||||
resetServerPathFlow: () => void
|
||||
handleAddServerPath: (kind: 'git' | 'folder') => Promise<void>
|
||||
} {
|
||||
const [serverPath, setServerPath] = useState('')
|
||||
const [isAddingServerPath, setIsAddingServerPath] = useState(false)
|
||||
const serverAddGenRef = useRef(0)
|
||||
|
||||
const resetServerPathFlow = useCallback((): void => {
|
||||
serverAddGenRef.current++
|
||||
setServerPath('')
|
||||
setIsAddingServerPath(false)
|
||||
}, [])
|
||||
|
||||
const handleAddServerPath = useCallback(
|
||||
async (kind: 'git' | 'folder'): Promise<void> => {
|
||||
const path = serverPath.trim()
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
const gen = ++serverAddGenRef.current
|
||||
setIsAddingServerPath(true)
|
||||
setAddProjectBusyLabel(kind === 'git' ? 'Scanning for repositories...' : 'Opening folder...')
|
||||
try {
|
||||
if (kind === 'git') {
|
||||
const attemptId = createNestedRepoTelemetryAttemptId()
|
||||
const runtimeKind = getNestedRepoRuntimeKind(null)
|
||||
const supportsStreamingScan = runtimeKind !== 'runtime'
|
||||
const scanId = supportsStreamingScan ? createNestedRepoScanId() : null
|
||||
if (scanId) {
|
||||
setActiveNestedScanId(scanId)
|
||||
setNestedScanInProgress(true)
|
||||
}
|
||||
const scan = await scanNestedRepos(
|
||||
path,
|
||||
undefined,
|
||||
scanId
|
||||
? {
|
||||
scanId,
|
||||
onProgress: (progressScan) => {
|
||||
if (
|
||||
gen !== serverAddGenRef.current ||
|
||||
progressScan.selectedPathKind !== 'non_git_folder' ||
|
||||
progressScan.repos.length === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
showNestedRepoReview({
|
||||
scan: progressScan,
|
||||
selectedPath: path,
|
||||
connectionId: null,
|
||||
attemptId,
|
||||
runtimeKind,
|
||||
inProgress: true,
|
||||
scanId
|
||||
})
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
if (gen !== serverAddGenRef.current) {
|
||||
return
|
||||
}
|
||||
setNestedScanInProgress(false)
|
||||
setActiveNestedScanId(null)
|
||||
track(
|
||||
'add_repo_nested_scan_result',
|
||||
buildNestedRepoScanTelemetry({
|
||||
attemptId,
|
||||
surface: 'sidebar',
|
||||
runtimeKind,
|
||||
scan
|
||||
})
|
||||
)
|
||||
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
|
||||
showNestedRepoReview({
|
||||
scan,
|
||||
selectedPath: path,
|
||||
connectionId: null,
|
||||
attemptId,
|
||||
runtimeKind,
|
||||
inProgress: false,
|
||||
scanId
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
setAddProjectBusyLabel(kind === 'git' ? 'Opening project...' : 'Opening folder...')
|
||||
const repo = await addRepoPath(path, kind)
|
||||
if (gen !== serverAddGenRef.current) {
|
||||
return
|
||||
}
|
||||
if (repo && isGitRepoKind(repo)) {
|
||||
setAddedRepo(repo)
|
||||
setExistingWorkspaceSource('runtime_server_path')
|
||||
await fetchWorktrees(repo.id)
|
||||
if (gen !== serverAddGenRef.current) {
|
||||
return
|
||||
}
|
||||
setStep('setup')
|
||||
} else if (repo) {
|
||||
// Why: folder repos skip the Git worktree setup step; their synthetic
|
||||
// root workspace is opened by the folder add flow.
|
||||
closeModal()
|
||||
}
|
||||
} finally {
|
||||
if (gen === serverAddGenRef.current) {
|
||||
setNestedScanInProgress(false)
|
||||
setActiveNestedScanId(null)
|
||||
setIsAddingServerPath(false)
|
||||
setAddProjectBusyLabel(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
addRepoPath,
|
||||
closeModal,
|
||||
fetchWorktrees,
|
||||
getNestedRepoRuntimeKind,
|
||||
scanNestedRepos,
|
||||
serverPath,
|
||||
setActiveNestedScanId,
|
||||
setAddProjectBusyLabel,
|
||||
setAddedRepo,
|
||||
setExistingWorkspaceSource,
|
||||
setNestedScanInProgress,
|
||||
setStep,
|
||||
showNestedRepoReview
|
||||
]
|
||||
)
|
||||
|
||||
return { serverPath, isAddingServerPath, setServerPath, resetServerPathFlow, handleAddServerPath }
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from 'react'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import {
|
||||
effectiveExternalWorktreeVisibility,
|
||||
isLegacyRepoForExternalWorktreeVisibility
|
||||
} from '../../../../shared/worktree-ownership'
|
||||
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import type { AddRepoSetupStepAction } from '../../../../shared/telemetry-events'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization'
|
||||
import {
|
||||
buildAddRepoExistingWorkspacesTelemetry,
|
||||
shouldTrackAddRepoExistingWorkspacesDetected
|
||||
} from './add-repo-existing-workspaces-telemetry'
|
||||
import { getProjectAddedPrimaryBranchName } from './AddRepoSetupStep'
|
||||
|
||||
export function useAddRepoSetupActions({
|
||||
addedRepo,
|
||||
existingWorkspaceSource,
|
||||
isSetupStep,
|
||||
projectId,
|
||||
closeModal,
|
||||
openModal,
|
||||
openSettingsPage,
|
||||
openSettingsTarget,
|
||||
fetchWorktrees,
|
||||
resetState,
|
||||
setupActionGenRef,
|
||||
setAddedRepo
|
||||
}: {
|
||||
addedRepo: Repo | null
|
||||
existingWorkspaceSource: AddRepoExistingWorkspaceSource | null
|
||||
isSetupStep: boolean
|
||||
projectId: string
|
||||
closeModal: () => void
|
||||
openModal: (
|
||||
modal: 'new-workspace-composer',
|
||||
data: { initialRepoId: string; prefilledName?: string; telemetrySource: 'sidebar' }
|
||||
) => void
|
||||
openSettingsPage: () => void
|
||||
openSettingsTarget: (target: { pane: 'repo'; repoId: string }) => void
|
||||
fetchWorktrees: (repoId: string) => Promise<unknown>
|
||||
resetState: () => void
|
||||
setupActionGenRef: MutableRefObject<number>
|
||||
setAddedRepo: (repo: Repo | null) => void
|
||||
}): {
|
||||
hiddenWorktreeCount: number
|
||||
primaryBranchName: string | null
|
||||
trackSetupAction: (action: AddRepoSetupStepAction) => void
|
||||
finishImportedRepoWithoutOpening: () => Promise<void>
|
||||
handleCreateWorktree: (name?: string) => void
|
||||
handleStartPrimaryWorktree: () => void
|
||||
handleConfigureRepo: () => void
|
||||
handleUseExistingWorktrees: () => Promise<void>
|
||||
} {
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
|
||||
const updateRepo = useAppStore((s) => s.updateRepo)
|
||||
const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
|
||||
const worktrees = useMemo(() => worktreesByRepo[projectId] ?? [], [worktreesByRepo, projectId])
|
||||
const detectedResult = projectId ? detectedWorktreesByRepo[projectId] : undefined
|
||||
const hiddenWorktreeCount =
|
||||
detectedResult?.authoritative === true
|
||||
? detectedResult.worktrees.filter(
|
||||
(worktree) => !worktree.selectedCheckout && worktree.ownership !== 'orca-managed'
|
||||
).length
|
||||
: 0
|
||||
const otherWorktreesVisible = addedRepo
|
||||
? effectiveExternalWorktreeVisibility(
|
||||
addedRepo,
|
||||
isLegacyRepoForExternalWorktreeVisibility(addedRepo)
|
||||
) === 'show'
|
||||
: false
|
||||
|
||||
// Why: sort by recent activity with alphabetical fallback.
|
||||
const sortedWorktrees = useMemo(() => {
|
||||
return [...worktrees].sort((a, b) => {
|
||||
if (a.lastActivityAt !== b.lastActivityAt) {
|
||||
return b.lastActivityAt - a.lastActivityAt
|
||||
}
|
||||
return a.displayName.localeCompare(b.displayName)
|
||||
})
|
||||
}, [worktrees])
|
||||
const primaryWorktree = useMemo(
|
||||
() => sortedWorktrees.find((worktree) => worktree.isMainWorktree) ?? null,
|
||||
[sortedWorktrees]
|
||||
)
|
||||
const primaryBranchName = getProjectAddedPrimaryBranchName(primaryWorktree)
|
||||
const existingWorkspaceTelemetry = useMemo(
|
||||
() => buildAddRepoExistingWorkspacesTelemetry(existingWorkspaceSource, sortedWorktrees),
|
||||
[existingWorkspaceSource, sortedWorktrees]
|
||||
)
|
||||
|
||||
const detectedTelemetryTrackedRef = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isSetupStep ||
|
||||
!projectId ||
|
||||
!existingWorkspaceTelemetry ||
|
||||
!shouldTrackAddRepoExistingWorkspacesDetected(existingWorkspaceTelemetry) ||
|
||||
detectedTelemetryTrackedRef.current.has(projectId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
detectedTelemetryTrackedRef.current.add(projectId)
|
||||
track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry)
|
||||
}, [existingWorkspaceTelemetry, isSetupStep, projectId])
|
||||
|
||||
const trackSetupAction = useCallback(
|
||||
(action: AddRepoSetupStepAction): void => {
|
||||
track('add_repo_setup_step_action', {
|
||||
action,
|
||||
...(existingWorkspaceTelemetry
|
||||
? {
|
||||
source: existingWorkspaceTelemetry.source,
|
||||
existing_workspace_count: existingWorkspaceTelemetry.existing_workspace_count,
|
||||
existing_linked_workspace_count:
|
||||
existingWorkspaceTelemetry.existing_linked_workspace_count
|
||||
}
|
||||
: {})
|
||||
})
|
||||
},
|
||||
[existingWorkspaceTelemetry]
|
||||
)
|
||||
|
||||
const handleCreateWorktree = useCallback(
|
||||
(name?: string): void => {
|
||||
trackSetupAction('create_worktree')
|
||||
// Why: let the dialog close animation finish before the composer takes focus.
|
||||
closeModal()
|
||||
setTimeout(() => {
|
||||
openModal('new-workspace-composer', {
|
||||
initialRepoId: projectId,
|
||||
...(name ? { prefilledName: name } : {}),
|
||||
telemetrySource: 'sidebar'
|
||||
})
|
||||
}, 150)
|
||||
},
|
||||
[closeModal, openModal, projectId, trackSetupAction]
|
||||
)
|
||||
|
||||
const handleStartPrimaryWorktree = useCallback((): void => {
|
||||
if (!primaryWorktree) {
|
||||
return
|
||||
}
|
||||
trackSetupAction('open_primary')
|
||||
closeModal()
|
||||
if (useAppStore.getState().hideDefaultBranchWorkspace) {
|
||||
setHideDefaultBranchWorkspace(false)
|
||||
}
|
||||
activateAndRevealWorktree(primaryWorktree.id)
|
||||
}, [closeModal, primaryWorktree, setHideDefaultBranchWorkspace, trackSetupAction])
|
||||
|
||||
const handleConfigureRepo = useCallback((): void => {
|
||||
trackSetupAction('configure')
|
||||
closeModal()
|
||||
openSettingsTarget({ pane: 'repo', repoId: projectId })
|
||||
openSettingsPage()
|
||||
}, [closeModal, openSettingsTarget, openSettingsPage, projectId, trackSetupAction])
|
||||
|
||||
const finishImportedRepoWithoutOpening = useCallback(async (): Promise<void> => {
|
||||
const importedRepoId = projectId
|
||||
closeModal()
|
||||
resetState()
|
||||
if (!importedRepoId) {
|
||||
return
|
||||
}
|
||||
await fetchWorktrees(importedRepoId)
|
||||
const state = useAppStore.getState()
|
||||
finalizeImportedRepoAfterSkip(state, importedRepoId)
|
||||
}, [closeModal, fetchWorktrees, projectId, resetState])
|
||||
|
||||
const handleUseExistingWorktrees = useCallback(async (): Promise<void> => {
|
||||
if (!projectId) {
|
||||
return
|
||||
}
|
||||
const gen = ++setupActionGenRef.current
|
||||
trackSetupAction('open_existing')
|
||||
if (!otherWorktreesVisible) {
|
||||
const updated = await updateRepo(projectId, { externalWorktreeVisibility: 'show' })
|
||||
if (gen !== setupActionGenRef.current) {
|
||||
return
|
||||
}
|
||||
if (updated && addedRepo) {
|
||||
setAddedRepo({ ...addedRepo, externalWorktreeVisibility: 'show' })
|
||||
}
|
||||
await fetchWorktrees(projectId)
|
||||
if (gen !== setupActionGenRef.current) {
|
||||
return
|
||||
}
|
||||
}
|
||||
await finishImportedRepoWithoutOpening()
|
||||
}, [
|
||||
addedRepo,
|
||||
fetchWorktrees,
|
||||
finishImportedRepoWithoutOpening,
|
||||
otherWorktreesVisible,
|
||||
projectId,
|
||||
setupActionGenRef,
|
||||
setAddedRepo,
|
||||
trackSetupAction,
|
||||
updateRepo
|
||||
])
|
||||
|
||||
return {
|
||||
hiddenWorktreeCount,
|
||||
primaryBranchName,
|
||||
trackSetupAction,
|
||||
finishImportedRepoWithoutOpening,
|
||||
handleCreateWorktree,
|
||||
handleStartPrimaryWorktree,
|
||||
handleConfigureRepo,
|
||||
handleUseExistingWorktrees
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: the overview keeps its small display components beside the
|
||||
provider fetch wiring so the combined usage surface stays easy to audit. */
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
Coins,
|
||||
DatabaseZap,
|
||||
RefreshCw,
|
||||
Sparkles
|
||||
} from 'lucide-react'
|
||||
import { Activity, CalendarDays, Coins, DatabaseZap, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { Button } from '../ui/button'
|
||||
@@ -19,22 +9,12 @@ import {
|
||||
buildUsageOverview,
|
||||
formatUsageCost,
|
||||
formatUsageTokens,
|
||||
getRecentUsageDays,
|
||||
type UsageOverviewDailyPoint,
|
||||
type UsageOverviewModel,
|
||||
type UsageProviderOverview
|
||||
getRecentUsageDays
|
||||
} from './usage-overview-model'
|
||||
import { DailyIntensityGrid, ProviderUsageRow, TokenMixBar } from './usage-overview-sections'
|
||||
|
||||
const RECENT_DAY_COUNT = 42
|
||||
|
||||
const INTENSITY_CLASS: Record<UsageOverviewDailyPoint['intensity'], string> = {
|
||||
0: 'border-border/60 bg-muted/40',
|
||||
1: 'border-border/60 bg-muted-foreground/20',
|
||||
2: 'border-border/60 bg-muted-foreground/35',
|
||||
3: 'border-border/60 bg-muted-foreground/55',
|
||||
4: 'border-border/60 bg-foreground/75'
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null): string {
|
||||
if (value === null) {
|
||||
return 'n/a'
|
||||
@@ -49,200 +29,6 @@ function formatUpdatedAt(timestamp: number | null): string {
|
||||
return `Updated ${new Date(timestamp).toLocaleString()}`
|
||||
}
|
||||
|
||||
function formatDayLabel(day: string): string {
|
||||
const parsed = new Date(`${day}T12:00:00`)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return day
|
||||
}
|
||||
return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function TokenMixBar({ overview }: { overview: UsageOverviewModel }): React.JSX.Element {
|
||||
const segments = [
|
||||
{
|
||||
key: 'new-input',
|
||||
label: 'New input',
|
||||
value: overview.newInputTokens,
|
||||
className: 'bg-foreground'
|
||||
},
|
||||
{
|
||||
key: 'output',
|
||||
label: 'Output',
|
||||
value: overview.outputTokens,
|
||||
className: 'bg-muted-foreground'
|
||||
},
|
||||
{
|
||||
key: 'cache',
|
||||
label: 'Cache',
|
||||
value: overview.cacheTokens,
|
||||
className: 'bg-border'
|
||||
}
|
||||
]
|
||||
// Why: Codex cached input is a subset of input. The overview model normalizes
|
||||
// that into new/cache buckets so the visual mix does not double-count it.
|
||||
const mixTotal = segments.reduce((sum, segment) => sum + segment.value, 0)
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">Token mix</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Combined input, output, and cache tokens across enabled providers.
|
||||
</p>
|
||||
</div>
|
||||
{overview.reasoningTokens > 0 ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{formatUsageTokens(overview.reasoningTokens)} reasoning
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mixTotal > 0 ? (
|
||||
<div
|
||||
className="flex h-3 overflow-hidden rounded-full border border-border/60 bg-muted"
|
||||
aria-label="Combined token mix"
|
||||
>
|
||||
{segments.map((segment) =>
|
||||
segment.value > 0 ? (
|
||||
<div
|
||||
key={segment.key}
|
||||
className={segment.className}
|
||||
style={{ width: `${(segment.value / mixTotal) * 100}%` }}
|
||||
aria-label={`${segment.label}: ${segment.value.toLocaleString()} tokens`}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3 rounded-full border border-dashed border-border/60 bg-muted/40" />
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
{segments.map((segment) => (
|
||||
<div key={segment.key} className="flex min-w-0 items-center gap-2">
|
||||
<span className={`size-2 shrink-0 rounded-full ${segment.className}`} />
|
||||
<span className="min-w-0 truncate">
|
||||
{segment.label}: {formatUsageTokens(segment.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DailyIntensityGrid({
|
||||
days,
|
||||
bestDay
|
||||
}: {
|
||||
days: UsageOverviewDailyPoint[]
|
||||
bestDay: UsageOverviewDailyPoint | null
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">Daily intensity</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recent combined Claude, Codex, and OpenCode token activity.
|
||||
</p>
|
||||
</div>
|
||||
{bestDay && bestDay.totalTokens > 0 ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
Best: {formatDayLabel(bestDay.day)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid grid-cols-[repeat(14,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(21,minmax(0,1fr))]"
|
||||
aria-label="Recent token activity heatmap"
|
||||
>
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day.day}
|
||||
className={`aspect-square min-h-3 rounded-[2px] border ${INTENSITY_CLASS[day.intensity]}`}
|
||||
aria-label={`${day.day}: ${day.totalTokens.toLocaleString()} tokens`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>{formatDayLabel(days[0]?.day ?? '')}</span>
|
||||
<span>Less</span>
|
||||
<div className="flex items-center gap-1" aria-hidden>
|
||||
{[0, 1, 2, 3, 4].map((intensity) => (
|
||||
<span
|
||||
key={intensity}
|
||||
className={`size-2 rounded-[2px] border ${INTENSITY_CLASS[intensity as UsageOverviewDailyPoint['intensity']]}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>More</span>
|
||||
<span>{formatDayLabel(days.at(-1)?.day ?? '')}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
provider,
|
||||
totalTokens,
|
||||
onEnable
|
||||
}: {
|
||||
provider: UsageProviderOverview
|
||||
totalTokens: number
|
||||
onEnable: () => void
|
||||
}): React.JSX.Element {
|
||||
const share = totalTokens > 0 ? provider.totalTokens / totalTokens : 0
|
||||
const status = provider.enabled ? (provider.isScanning ? 'Scanning' : 'Enabled') : 'Off'
|
||||
const statusVariant = provider.enabled ? 'secondary' : 'outline'
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h5 className="truncate text-sm font-semibold text-foreground">{provider.label}</h5>
|
||||
<Badge variant={statusVariant}>{status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{provider.topModel ?? 'No model yet'}
|
||||
{provider.topProject ? ` - ${provider.topProject}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{!provider.enabled ? (
|
||||
<Button variant="outline" size="xs" onClick={onEnable}>
|
||||
Enable
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
<span>{formatUsageTokens(provider.totalTokens)} tokens</span>
|
||||
<span>
|
||||
{provider.sessions.toLocaleString()} sessions - {provider.activityCount.toLocaleString()}{' '}
|
||||
{provider.activityLabel}
|
||||
</span>
|
||||
<span>{formatUsageCost(provider.estimatedCostUsd)}</span>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-foreground/75"
|
||||
style={{ width: `${Math.max(share * 100, provider.totalTokens > 0 ? 2 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{provider.lastScanError ? (
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="size-3" />
|
||||
{provider.lastScanError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UsageOverviewPane(): React.JSX.Element {
|
||||
const claudeScanState = useAppStore((state) => state.claudeUsageScanState)
|
||||
const claudeSummary = useAppStore((state) => state.claudeUsageSummary)
|
||||
@@ -441,7 +227,7 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{overview.providers.map((provider) => (
|
||||
<ProviderRow
|
||||
<ProviderUsageRow
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
totalTokens={overview.totalTokens}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
formatUsageCost,
|
||||
formatUsageTokens,
|
||||
type UsageOverviewDailyPoint,
|
||||
type UsageOverviewModel,
|
||||
type UsageProviderOverview
|
||||
} from './usage-overview-model'
|
||||
|
||||
const INTENSITY_CLASS: Record<UsageOverviewDailyPoint['intensity'], string> = {
|
||||
0: 'border-border/60 bg-muted/40',
|
||||
1: 'border-border/60 bg-muted-foreground/20',
|
||||
2: 'border-border/60 bg-muted-foreground/35',
|
||||
3: 'border-border/60 bg-muted-foreground/55',
|
||||
4: 'border-border/60 bg-foreground/75'
|
||||
}
|
||||
|
||||
function formatDayLabel(day: string): string {
|
||||
const parsed = new Date(`${day}T12:00:00`)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return day
|
||||
}
|
||||
return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export function TokenMixBar({ overview }: { overview: UsageOverviewModel }): React.JSX.Element {
|
||||
const segments = [
|
||||
{
|
||||
key: 'new-input',
|
||||
label: 'New input',
|
||||
value: overview.newInputTokens,
|
||||
className: 'bg-foreground'
|
||||
},
|
||||
{
|
||||
key: 'output',
|
||||
label: 'Output',
|
||||
value: overview.outputTokens,
|
||||
className: 'bg-muted-foreground'
|
||||
},
|
||||
{
|
||||
key: 'cache',
|
||||
label: 'Cache',
|
||||
value: overview.cacheTokens,
|
||||
className: 'bg-border'
|
||||
}
|
||||
]
|
||||
// Why: Codex cached input is a subset of input. The overview model normalizes
|
||||
// that into new/cache buckets so the visual mix does not double-count it.
|
||||
const mixTotal = segments.reduce((sum, segment) => sum + segment.value, 0)
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">Token mix</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Combined input, output, and cache tokens across enabled providers.
|
||||
</p>
|
||||
</div>
|
||||
{overview.reasoningTokens > 0 ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{formatUsageTokens(overview.reasoningTokens)} reasoning
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mixTotal > 0 ? (
|
||||
<div
|
||||
className="flex h-3 overflow-hidden rounded-full border border-border/60 bg-muted"
|
||||
aria-label="Combined token mix"
|
||||
>
|
||||
{segments.map((segment) =>
|
||||
segment.value > 0 ? (
|
||||
<div
|
||||
key={segment.key}
|
||||
className={segment.className}
|
||||
style={{ width: `${(segment.value / mixTotal) * 100}%` }}
|
||||
aria-label={`${segment.label}: ${segment.value.toLocaleString()} tokens`}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3 rounded-full border border-dashed border-border/60 bg-muted/40" />
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
{segments.map((segment) => (
|
||||
<div key={segment.key} className="flex min-w-0 items-center gap-2">
|
||||
<span className={`size-2 shrink-0 rounded-full ${segment.className}`} />
|
||||
<span className="min-w-0 truncate">
|
||||
{segment.label}: {formatUsageTokens(segment.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function DailyIntensityGrid({
|
||||
days,
|
||||
bestDay
|
||||
}: {
|
||||
days: UsageOverviewDailyPoint[]
|
||||
bestDay: UsageOverviewDailyPoint | null
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">Daily intensity</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recent combined Claude, Codex, and OpenCode token activity.
|
||||
</p>
|
||||
</div>
|
||||
{bestDay && bestDay.totalTokens > 0 ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
Best: {formatDayLabel(bestDay.day)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid grid-cols-[repeat(14,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(21,minmax(0,1fr))]"
|
||||
aria-label="Recent token activity heatmap"
|
||||
>
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day.day}
|
||||
className={`aspect-square min-h-3 rounded-[2px] border ${INTENSITY_CLASS[day.intensity]}`}
|
||||
aria-label={`${day.day}: ${day.totalTokens.toLocaleString()} tokens`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>{formatDayLabel(days[0]?.day ?? '')}</span>
|
||||
<span>Less</span>
|
||||
<div className="flex items-center gap-1" aria-hidden>
|
||||
{[0, 1, 2, 3, 4].map((intensity) => (
|
||||
<span
|
||||
key={intensity}
|
||||
className={`size-2 rounded-[2px] border ${INTENSITY_CLASS[intensity as UsageOverviewDailyPoint['intensity']]}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>More</span>
|
||||
<span>{formatDayLabel(days.at(-1)?.day ?? '')}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderUsageRow({
|
||||
provider,
|
||||
totalTokens,
|
||||
onEnable
|
||||
}: {
|
||||
provider: UsageProviderOverview
|
||||
totalTokens: number
|
||||
onEnable: () => void
|
||||
}): React.JSX.Element {
|
||||
const share = totalTokens > 0 ? provider.totalTokens / totalTokens : 0
|
||||
const status = provider.enabled ? (provider.isScanning ? 'Scanning' : 'Enabled') : 'Off'
|
||||
const statusVariant = provider.enabled ? 'secondary' : 'outline'
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h5 className="truncate text-sm font-semibold text-foreground">{provider.label}</h5>
|
||||
<Badge variant={statusVariant}>{status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{provider.topModel ?? 'No model yet'}
|
||||
{provider.topProject ? ` - ${provider.topProject}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{!provider.enabled ? (
|
||||
<Button variant="outline" size="xs" onClick={onEnable}>
|
||||
Enable
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
<span>{formatUsageTokens(provider.totalTokens)} tokens</span>
|
||||
<span>
|
||||
{provider.sessions.toLocaleString()} sessions - {provider.activityCount.toLocaleString()}{' '}
|
||||
{provider.activityLabel}
|
||||
</span>
|
||||
<span>{formatUsageCost(provider.estimatedCostUsd)}</span>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-foreground/75"
|
||||
style={{ width: `${Math.max(share * 100, provider.totalTokens > 0 ? 2 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{provider.lastScanError ? (
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="size-3" />
|
||||
{provider.lastScanError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,274 +1,23 @@
|
||||
/* eslint-disable max-lines -- Why: PortsStatusSegment keeps port scanning, grouping, row actions,
|
||||
and status-bar interaction tracking together so the popover state stays coherent. */
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
Plug,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
LoaderCircle,
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Plug, ChevronDown, ChevronRight, LoaderCircle } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
addressForPort,
|
||||
canStopWorkspacePort,
|
||||
goToWorkspacePortOwner,
|
||||
killWorkspacePortForTarget,
|
||||
openWorkspacePortInBrowser,
|
||||
refreshWorkspacePortScanAfterStop,
|
||||
scanWorkspacePortsForTarget,
|
||||
shouldOpenWorkspacePortInOrcaBrowser,
|
||||
workspacePortRuntimeTargetKey
|
||||
} from '@/lib/workspace-port-actions'
|
||||
import {
|
||||
getExternalWorkspacePorts,
|
||||
getWorkspacePortGroups,
|
||||
type WorkspacePortGroup
|
||||
} from '@/lib/workspace-port-groups'
|
||||
import { getExternalWorkspacePorts, getWorkspacePortGroups } from '@/lib/workspace-port-groups'
|
||||
import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu'
|
||||
import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy'
|
||||
import type { WorkspacePort } from '../../../../shared/workspace-ports'
|
||||
import { PortRow, WorkspaceGroupRows } from './ports-status-popover-rows'
|
||||
|
||||
type PortsStatusSegmentProps = {
|
||||
compact?: boolean
|
||||
iconOnly: boolean
|
||||
}
|
||||
|
||||
function PortAction({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
disabled?: boolean
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
onClick(event)
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-5 text-muted-foreground hover:text-foreground disabled:pointer-events-none disabled:text-muted-foreground/35"
|
||||
aria-label={label}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
{disabled ? <span className="inline-flex">{button}</span> : button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4} className="z-[70]">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function PortRow({
|
||||
port,
|
||||
activeWorktreeId,
|
||||
external
|
||||
}: {
|
||||
port: WorkspacePort
|
||||
activeWorktreeId: string | null
|
||||
external?: boolean
|
||||
}): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
|
||||
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
|
||||
const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan)
|
||||
const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
|
||||
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
|
||||
const openInOrcaBrowser = shouldOpenWorkspacePortInOrcaBrowser(settings)
|
||||
const canOpen = !openInOrcaBrowser || port.kind === 'workspace' || Boolean(activeWorktreeId)
|
||||
const canStop = canStopWorkspacePort(port)
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
void openWorkspacePortInBrowser({
|
||||
port,
|
||||
activeWorktreeId,
|
||||
runtimeTarget,
|
||||
createBrowserTab,
|
||||
setRemoteBrowserPageHandle,
|
||||
openInOrcaBrowser
|
||||
}).then((result) => {
|
||||
if (!result.ok) {
|
||||
toast.error('Failed to open browser', { description: result.reason })
|
||||
}
|
||||
})
|
||||
},
|
||||
[
|
||||
activeWorktreeId,
|
||||
createBrowserTab,
|
||||
openInOrcaBrowser,
|
||||
port,
|
||||
recordFeatureInteraction,
|
||||
runtimeTarget,
|
||||
setRemoteBrowserPageHandle
|
||||
]
|
||||
)
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
const address = addressForPort(port)
|
||||
void window.api.ui.writeClipboardText(address)
|
||||
toast.success(`Copied ${address}`)
|
||||
},
|
||||
[port, recordFeatureInteraction]
|
||||
)
|
||||
|
||||
const handleStop = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
if (!canStopWorkspacePort(port)) {
|
||||
return
|
||||
}
|
||||
recordFeatureInteraction('ports')
|
||||
const run = async (): Promise<void> => {
|
||||
const result = await killWorkspacePortForTarget(runtimeTarget, {
|
||||
repoId: port.owner.repoId,
|
||||
pid: port.pid,
|
||||
port: port.port
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast.error(result.reason)
|
||||
return
|
||||
}
|
||||
toast.success(`Stopped process on ${port.port}`)
|
||||
const refreshResult = await refreshWorkspacePortScanAfterStop({
|
||||
runtimeTarget,
|
||||
setWorkspacePortScan,
|
||||
setWorkspacePortScanRefreshing
|
||||
})
|
||||
if (!refreshResult.ok) {
|
||||
toast.error('Failed to refresh ports', {
|
||||
description: refreshResult.reason
|
||||
})
|
||||
}
|
||||
}
|
||||
void run()
|
||||
},
|
||||
[
|
||||
port,
|
||||
recordFeatureInteraction,
|
||||
runtimeTarget,
|
||||
setWorkspacePortScan,
|
||||
setWorkspacePortScanRefreshing
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group/port grid min-w-0 grid-cols-[4.5rem_minmax(0,1fr)] items-start gap-2 rounded-md px-2 py-1.5 hover:bg-accent/50">
|
||||
<span className="select-text font-mono text-[12px] font-semibold tabular-nums text-foreground">
|
||||
{port.port}
|
||||
</span>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="relative flex h-5 min-w-0 items-center">
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 select-text truncate text-[11px] text-muted-foreground">
|
||||
{processLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{processLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-md border border-border/40 bg-popover/95 px-0.5 opacity-0 shadow-xs transition-opacity group-hover/port:opacity-100 group-focus-within/port:opacity-100">
|
||||
<PortAction label="Open in Browser" onClick={handleOpen} disabled={!canOpen}>
|
||||
<ExternalLink className="size-3" />
|
||||
</PortAction>
|
||||
<PortAction label={`Copy ${addressForPort(port)}`} onClick={handleCopy}>
|
||||
<Copy className="size-3" />
|
||||
</PortAction>
|
||||
<PortAction label="Stop Process" disabled={!canStop} onClick={handleStop}>
|
||||
<Trash2 className="size-3" />
|
||||
</PortAction>
|
||||
</div>
|
||||
</div>
|
||||
<div className="select-text truncate text-[10px] text-muted-foreground/70">
|
||||
{external ? port.kind : addressForPort(port)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceGroupRows({
|
||||
group,
|
||||
activeWorktreeId
|
||||
}: {
|
||||
group: WorkspacePortGroup
|
||||
activeWorktreeId: string | null
|
||||
}): React.JSX.Element {
|
||||
const handleGoToWorkspace = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
const ownerPort = group.ports[0]
|
||||
if (!ownerPort || !goToWorkspacePortOwner(ownerPort)) {
|
||||
toast.error('Workspace unavailable')
|
||||
}
|
||||
},
|
||||
[group.ports]
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="border-t border-border/40 first:border-t-0">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-border/40 bg-popover px-3 py-2">
|
||||
<span className="min-w-0 truncate text-[12px] font-medium text-foreground">
|
||||
{group.displayName}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<PortAction
|
||||
label="Go to Worktree"
|
||||
onClick={handleGoToWorkspace}
|
||||
disabled={group.ports.length === 0}
|
||||
>
|
||||
<FolderOpen className="size-3" />
|
||||
</PortAction>
|
||||
<span className="font-mono text-[10px] text-muted-foreground/70">
|
||||
{group.ports.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
{group.ports.map((port) => (
|
||||
<PortRow key={port.id} port={port} activeWorktreeId={activeWorktreeId} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const scan = useAppStore((s) => s.workspacePortScan?.result ?? null)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { Copy, ExternalLink, FolderOpen, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
addressForPort,
|
||||
canStopWorkspacePort,
|
||||
goToWorkspacePortOwner,
|
||||
killWorkspacePortForTarget,
|
||||
openWorkspacePortInBrowser,
|
||||
refreshWorkspacePortScanAfterStop,
|
||||
shouldOpenWorkspacePortInOrcaBrowser
|
||||
} from '@/lib/workspace-port-actions'
|
||||
import type { WorkspacePortGroup } from '@/lib/workspace-port-groups'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { WorkspacePort } from '../../../../shared/workspace-ports'
|
||||
|
||||
function PortAction({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
disabled?: boolean
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
onClick(event)
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-5 text-muted-foreground hover:text-foreground disabled:pointer-events-none disabled:text-muted-foreground/35"
|
||||
aria-label={label}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
{disabled ? <span className="inline-flex">{button}</span> : button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4} className="z-[70]">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function PortRow({
|
||||
port,
|
||||
activeWorktreeId,
|
||||
external
|
||||
}: {
|
||||
port: WorkspacePort
|
||||
activeWorktreeId: string | null
|
||||
external?: boolean
|
||||
}): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
|
||||
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
|
||||
const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan)
|
||||
const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
|
||||
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
|
||||
const openInOrcaBrowser = shouldOpenWorkspacePortInOrcaBrowser(settings)
|
||||
const canOpen = !openInOrcaBrowser || port.kind === 'workspace' || Boolean(activeWorktreeId)
|
||||
const canStop = canStopWorkspacePort(port)
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
void openWorkspacePortInBrowser({
|
||||
port,
|
||||
activeWorktreeId,
|
||||
runtimeTarget,
|
||||
createBrowserTab,
|
||||
setRemoteBrowserPageHandle,
|
||||
openInOrcaBrowser
|
||||
}).then((result) => {
|
||||
if (!result.ok) {
|
||||
toast.error('Failed to open browser', { description: result.reason })
|
||||
}
|
||||
})
|
||||
},
|
||||
[
|
||||
activeWorktreeId,
|
||||
createBrowserTab,
|
||||
openInOrcaBrowser,
|
||||
port,
|
||||
recordFeatureInteraction,
|
||||
runtimeTarget,
|
||||
setRemoteBrowserPageHandle
|
||||
]
|
||||
)
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
recordFeatureInteraction('ports')
|
||||
const address = addressForPort(port)
|
||||
void window.api.ui.writeClipboardText(address)
|
||||
toast.success(`Copied ${address}`)
|
||||
},
|
||||
[port, recordFeatureInteraction]
|
||||
)
|
||||
|
||||
const handleStop = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
if (!canStopWorkspacePort(port)) {
|
||||
return
|
||||
}
|
||||
recordFeatureInteraction('ports')
|
||||
const run = async (): Promise<void> => {
|
||||
const result = await killWorkspacePortForTarget(runtimeTarget, {
|
||||
repoId: port.owner.repoId,
|
||||
pid: port.pid,
|
||||
port: port.port
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast.error(result.reason)
|
||||
return
|
||||
}
|
||||
toast.success(`Stopped process on ${port.port}`)
|
||||
const refreshResult = await refreshWorkspacePortScanAfterStop({
|
||||
runtimeTarget,
|
||||
setWorkspacePortScan,
|
||||
setWorkspacePortScanRefreshing
|
||||
})
|
||||
if (!refreshResult.ok) {
|
||||
toast.error('Failed to refresh ports', {
|
||||
description: refreshResult.reason
|
||||
})
|
||||
}
|
||||
}
|
||||
void run()
|
||||
},
|
||||
[
|
||||
port,
|
||||
recordFeatureInteraction,
|
||||
runtimeTarget,
|
||||
setWorkspacePortScan,
|
||||
setWorkspacePortScanRefreshing
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group/port grid min-w-0 grid-cols-[4.5rem_minmax(0,1fr)] items-start gap-2 rounded-md px-2 py-1.5 hover:bg-accent/50">
|
||||
<span className="select-text font-mono text-[12px] font-semibold tabular-nums text-foreground">
|
||||
{port.port}
|
||||
</span>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="relative flex h-5 min-w-0 items-center">
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 select-text truncate text-[11px] text-muted-foreground">
|
||||
{processLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{processLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-0.5 rounded-md border border-border/40 bg-popover/95 px-0.5 opacity-0 shadow-xs transition-opacity group-hover/port:opacity-100 group-focus-within/port:opacity-100">
|
||||
<PortAction label="Open in Browser" onClick={handleOpen} disabled={!canOpen}>
|
||||
<ExternalLink className="size-3" />
|
||||
</PortAction>
|
||||
<PortAction label={`Copy ${addressForPort(port)}`} onClick={handleCopy}>
|
||||
<Copy className="size-3" />
|
||||
</PortAction>
|
||||
<PortAction label="Stop Process" disabled={!canStop} onClick={handleStop}>
|
||||
<Trash2 className="size-3" />
|
||||
</PortAction>
|
||||
</div>
|
||||
</div>
|
||||
<div className="select-text truncate text-[10px] text-muted-foreground/70">
|
||||
{external ? port.kind : addressForPort(port)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceGroupRows({
|
||||
group,
|
||||
activeWorktreeId
|
||||
}: {
|
||||
group: WorkspacePortGroup
|
||||
activeWorktreeId: string | null
|
||||
}): React.JSX.Element {
|
||||
const handleGoToWorkspace = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
const ownerPort = group.ports[0]
|
||||
if (!ownerPort || !goToWorkspacePortOwner(ownerPort)) {
|
||||
toast.error('Workspace unavailable')
|
||||
}
|
||||
},
|
||||
[group.ports]
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="border-t border-border/40 first:border-t-0">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-border/40 bg-popover px-3 py-2">
|
||||
<span className="min-w-0 truncate text-[12px] font-medium text-foreground">
|
||||
{group.displayName}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<PortAction
|
||||
label="Go to Worktree"
|
||||
onClick={handleGoToWorkspace}
|
||||
disabled={group.ports.length === 0}
|
||||
>
|
||||
<FolderOpen className="size-3" />
|
||||
</PortAction>
|
||||
<span className="font-mono text-[10px] text-muted-foreground/70">
|
||||
{group.ports.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
{group.ports.map((port) => (
|
||||
<PortRow key={port.id} port={port} activeWorktreeId={activeWorktreeId} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: editor tab rendering, drag behavior, rename handling, and its context menu share one tightly-coupled tab surface. */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import {
|
||||
X,
|
||||
GitCompareArrows,
|
||||
Copy,
|
||||
Eye,
|
||||
ShieldAlert,
|
||||
ExternalLink,
|
||||
Columns2,
|
||||
Rows2,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { X, GitCompareArrows, Eye, ShieldAlert, Pin } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { basename, normalizeRelativePath } from '@/lib/path'
|
||||
@@ -43,18 +23,7 @@ import {
|
||||
type DropIndicator
|
||||
} from './drop-indicator'
|
||||
import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls'
|
||||
import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
||||
import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isLinux = navigator.userAgent.includes('Linux')
|
||||
|
||||
/** Platform-appropriate label: macOS → Finder, Windows → File Explorer, Linux → Files */
|
||||
const revealLabel = isMac
|
||||
? 'Reveal in Finder'
|
||||
: isLinux
|
||||
? 'Open Containing Folder'
|
||||
: 'Reveal in File Explorer'
|
||||
import { EditorFileTabContextMenu } from './EditorFileTabContextMenu'
|
||||
|
||||
export default function EditorFileTab({
|
||||
file,
|
||||
@@ -397,132 +366,28 @@ export default function EditorFileTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none fixed size-px opacity-0"
|
||||
style={{ left: menuPoint.x, top: menuPoint.y }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48"
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!skipMenuFocusRestoreRef.current) {
|
||||
return
|
||||
}
|
||||
skipMenuFocusRestoreRef.current = false
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('up', file.tabId ?? file.id)}>
|
||||
<Rows2 className="mr-1.5 size-3.5" />
|
||||
Split Up
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('down', file.tabId ?? file.id)}>
|
||||
<Rows2 className="mr-1.5 size-3.5" />
|
||||
Split Down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('left', file.tabId ?? file.id)}>
|
||||
<Columns2 className="mr-1.5 size-3.5" />
|
||||
Split Left
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('right', file.tabId ?? file.id)}>
|
||||
<Columns2 className="mr-1.5 size-3.5" />
|
||||
Split Right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={!canRename || isRenaming}
|
||||
onSelect={() => {
|
||||
skipMenuFocusRestoreRef.current = true
|
||||
onActivate()
|
||||
openRenameInput()
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-1.5 size-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onTogglePin}>
|
||||
{isPinned ? (
|
||||
<PinOff className="mr-1.5 size-3.5" />
|
||||
) : (
|
||||
<Pin className="mr-1.5 size-3.5" />
|
||||
)}
|
||||
{isPinned ? 'Unpin Tab' : 'Pin Tab'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
|
||||
Close
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onCloseAll}>Close All Editor Tabs</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
|
||||
Close Tabs To The Right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{canShowMarkdownPreview && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onActivate()
|
||||
openMarkdownPreview(
|
||||
{
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
worktreeId: file.worktreeId,
|
||||
runtimeEnvironmentId: file.runtimeEnvironmentId,
|
||||
language: resolvedLanguage
|
||||
},
|
||||
{ sourceFileId: file.id }
|
||||
)
|
||||
}}
|
||||
>
|
||||
Open Markdown Preview
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(file.filePath)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
Copy Path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(file.relativePath)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
Copy Relative Path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
if (
|
||||
shouldBlockEditorTabLocalOpen(
|
||||
useAppStore.getState().settings,
|
||||
file.runtimeEnvironmentId,
|
||||
repo?.connectionId ?? null
|
||||
)
|
||||
) {
|
||||
showLocalPathOpenBlockedToast()
|
||||
return
|
||||
}
|
||||
window.api.shell.openPath(file.filePath)
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
||||
{revealLabel}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<EditorFileTabContextMenu
|
||||
open={menuOpen}
|
||||
menuPoint={menuPoint}
|
||||
file={file}
|
||||
isPinned={isPinned}
|
||||
isRenaming={isRenaming}
|
||||
hasTabsToRight={hasTabsToRight}
|
||||
canRename={canRename}
|
||||
canShowMarkdownPreview={canShowMarkdownPreview}
|
||||
resolvedLanguage={resolvedLanguage}
|
||||
repoConnectionId={repo?.connectionId ?? null}
|
||||
skipMenuFocusRestoreRef={skipMenuFocusRestoreRef}
|
||||
onOpenChange={setMenuOpen}
|
||||
onActivate={onActivate}
|
||||
onOpenRenameInput={openRenameInput}
|
||||
onTogglePin={onTogglePin}
|
||||
onClose={onClose}
|
||||
onCloseAll={onCloseAll}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onSplitGroup={onSplitGroup}
|
||||
onOpenMarkdownPreview={openMarkdownPreview}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Copy, ExternalLink, Columns2, Rows2, Pencil, Pin, PinOff } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAppStore } from '@/store'
|
||||
import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
||||
import type { OpenFile } from '../../store/slices/editor'
|
||||
import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isLinux = navigator.userAgent.includes('Linux')
|
||||
|
||||
/** Platform-appropriate label: macOS → Finder, Windows → File Explorer, Linux → Files */
|
||||
const revealLabel = isMac
|
||||
? 'Reveal in Finder'
|
||||
: isLinux
|
||||
? 'Open Containing Folder'
|
||||
: 'Reveal in File Explorer'
|
||||
|
||||
type EditorFileTabContextMenuProps = {
|
||||
open: boolean
|
||||
menuPoint: { x: number; y: number }
|
||||
file: OpenFile & { tabId?: string }
|
||||
isPinned: boolean
|
||||
isRenaming: boolean
|
||||
hasTabsToRight: boolean
|
||||
canRename: boolean
|
||||
canShowMarkdownPreview: boolean
|
||||
resolvedLanguage: string
|
||||
repoConnectionId: string | null
|
||||
skipMenuFocusRestoreRef: React.MutableRefObject<boolean>
|
||||
onOpenChange: (open: boolean) => void
|
||||
onActivate: () => void
|
||||
onOpenRenameInput: () => void
|
||||
onTogglePin: () => void
|
||||
onClose: () => void
|
||||
onCloseAll: () => void
|
||||
onCloseToRight: () => void
|
||||
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
|
||||
onOpenMarkdownPreview: (
|
||||
file: {
|
||||
filePath: string
|
||||
relativePath: string
|
||||
worktreeId: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
language: string
|
||||
},
|
||||
options: { sourceFileId: string }
|
||||
) => void
|
||||
}
|
||||
|
||||
export function EditorFileTabContextMenu({
|
||||
open,
|
||||
menuPoint,
|
||||
file,
|
||||
isPinned,
|
||||
isRenaming,
|
||||
hasTabsToRight,
|
||||
canRename,
|
||||
canShowMarkdownPreview,
|
||||
resolvedLanguage,
|
||||
repoConnectionId,
|
||||
skipMenuFocusRestoreRef,
|
||||
onOpenChange,
|
||||
onActivate,
|
||||
onOpenRenameInput,
|
||||
onTogglePin,
|
||||
onClose,
|
||||
onCloseAll,
|
||||
onCloseToRight,
|
||||
onSplitGroup,
|
||||
onOpenMarkdownPreview
|
||||
}: EditorFileTabContextMenuProps): React.JSX.Element {
|
||||
const sourceVisibleTabId = file.tabId ?? file.id
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none fixed size-px opacity-0"
|
||||
style={{ left: menuPoint.x, top: menuPoint.y }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48"
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!skipMenuFocusRestoreRef.current) {
|
||||
return
|
||||
}
|
||||
skipMenuFocusRestoreRef.current = false
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('up', sourceVisibleTabId)}>
|
||||
<Rows2 className="mr-1.5 size-3.5" />
|
||||
Split Up
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('down', sourceVisibleTabId)}>
|
||||
<Rows2 className="mr-1.5 size-3.5" />
|
||||
Split Down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('left', sourceVisibleTabId)}>
|
||||
<Columns2 className="mr-1.5 size-3.5" />
|
||||
Split Left
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('right', sourceVisibleTabId)}>
|
||||
<Columns2 className="mr-1.5 size-3.5" />
|
||||
Split Right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={!canRename || isRenaming}
|
||||
onSelect={() => {
|
||||
skipMenuFocusRestoreRef.current = true
|
||||
onActivate()
|
||||
onOpenRenameInput()
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-1.5 size-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onTogglePin}>
|
||||
{isPinned ? <PinOff className="mr-1.5 size-3.5" /> : <Pin className="mr-1.5 size-3.5" />}
|
||||
{isPinned ? 'Unpin Tab' : 'Pin Tab'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
|
||||
Close
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onCloseAll}>Close All Editor Tabs</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
|
||||
Close Tabs To The Right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{canShowMarkdownPreview ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onActivate()
|
||||
onOpenMarkdownPreview(
|
||||
{
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
worktreeId: file.worktreeId,
|
||||
runtimeEnvironmentId: file.runtimeEnvironmentId,
|
||||
language: resolvedLanguage
|
||||
},
|
||||
{ sourceFileId: file.id }
|
||||
)
|
||||
}}
|
||||
>
|
||||
Open Markdown Preview
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(file.filePath)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
Copy Path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(file.relativePath)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
Copy Relative Path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
if (
|
||||
shouldBlockEditorTabLocalOpen(
|
||||
useAppStore.getState().settings,
|
||||
file.runtimeEnvironmentId,
|
||||
repoConnectionId
|
||||
)
|
||||
) {
|
||||
showLocalPathOpenBlockedToast()
|
||||
return
|
||||
}
|
||||
window.api.shell.openPath(file.filePath)
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
||||
{revealLabel}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
/* eslint-disable max-lines -- Why: the supported-agent catalog, icon glyphs,
|
||||
and fallback icon renderer must stay in one place so agent option labels and
|
||||
status-surface icons do not drift. */
|
||||
import React from 'react'
|
||||
import type React from 'react'
|
||||
import { ClaudeIcon, DroidIcon, OpenAIIcon } from '@/components/status-bar/icons'
|
||||
import openClaudeLogoUrl from '../../../../resources/openclaude-logo.png?url'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import {
|
||||
AgentLetterIcon,
|
||||
AiderIcon,
|
||||
CopilotIcon,
|
||||
KiloIcon,
|
||||
OmpIcon,
|
||||
PiIcon
|
||||
} from './agent-icon-glyphs'
|
||||
|
||||
export type AgentCatalogEntry = {
|
||||
id: TuiAgent
|
||||
@@ -242,150 +247,6 @@ export function getAgentLabel(agent: TuiAgent): string {
|
||||
return AGENT_CATALOG.find((entry) => entry.id === agent)?.label ?? agent
|
||||
}
|
||||
|
||||
function PiIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
// SVG sourced from pi.dev/favicon.svg — the π shape rendered in currentColor.
|
||||
// Why: className="text-current" opts out of shadcn's Select rule that forces
|
||||
// text-muted-foreground on any <svg> that lacks a text-* class.
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
width={size}
|
||||
viewBox="0 0 800 800"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-current"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
|
||||
/>
|
||||
<path fill="currentColor" d="M517.36 400 H634.72 V634.72 H517.36 Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function OmpIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
const gradientId = `${React.useId().replace(/:/g, '')}-omp-gradient`
|
||||
|
||||
// SVG sourced from omp.sh's homepage mark. Why: omp.sh/favicon.svg includes
|
||||
// a dark square background, while the homepage mark is transparent.
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="oklch(0.7 0.24 340)" />
|
||||
<stop offset=".5" stopColor="oklch(0.62 0.21 295)" />
|
||||
<stop offset="1" stopColor="oklch(0.81 0.14 200)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill={`url(#${gradientId})`} d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function KiloIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
// SVG sourced from Kilo-Org/kilocode:packages/kilo-vscode/assets/icons/kilo-light.svg.
|
||||
// Why: the Google favicon for kilo.ai is black-on-black at small sizes and
|
||||
// is illegible. Inlining the brand mark (yellow on black) keeps it readable
|
||||
// on both light and dark app themes without using currentColor — this logo
|
||||
// is intentionally brand-colored, not theme-colored.
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 512 512"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
style={{ borderRadius: 2 }}
|
||||
>
|
||||
<path d="M512 0H0V512H512V0Z" fill="black" />
|
||||
<path
|
||||
d="M322 377H377V421H307.857L278 391.143V322H322V377ZM421 307.857L391.143 278H322V322L377 322V377H421V307.857ZM234 278H190V322H234V278ZM91 391.143L120.857 421H234V377H135V278H91V391.143ZM371.172 189.999V120.856L341.315 90.9995H278V135H327.172V189.999H278V233.999H421V189.999H371.172ZM135 91H91V233.999H135V184.5H190V233.999H234V184.5L190 140.5H135V91ZM234 91H190V140.5H234V91Z"
|
||||
fill="#FAF74F"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AiderIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
// SVG sourced from aider.chat/assets/icons/safari-pinned-tab.svg.
|
||||
// Why: className="text-current" opts out of shadcn's Select rule that forces
|
||||
// text-muted-foreground on any <svg> that lacks a text-* class.
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 436 436"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="text-current"
|
||||
>
|
||||
<g transform="translate(0,436) scale(0.1,-0.1)" fill="currentColor" stroke="none">
|
||||
<path d="M0 2180 l0 -2180 2180 0 2180 0 0 2180 0 2180 -2180 0 -2180 0 0 -2180z m2705 1818 c20 -20 28 -121 30 -398 l2 -305 216 -5 c118 -3 218 -8 222 -12 3 -3 10 -46 15 -95 5 -48 16 -126 25 -172 17 -86 17 -81 -17 -233 -14 -67 -13 -365 2 -438 21 -100 22 -159 5 -247 -24 -122 -24 -363 1 -458 23 -88 23 -213 1 -330 -9 -49 -17 -109 -17 -132 l0 -43 203 0 c111 0 208 -4 216 -9 10 -6 18 -51 27 -148 8 -76 16 -152 20 -168 7 -39 -23 -361 -37 -387 -10 -18 -21 -19 -214 -16 -135 2 -208 7 -215 14 -22 22 -33 301 -21 501 6 102 8 189 5 194 -8 13 -417 12 -431 -2 -12 -12 -8 -146 8 -261 8 -55 8 -95 1 -140 -6 -35 -14 -99 -17 -143 -9 -123 -14 -141 -41 -154 -18 -8 -217 -11 -679 -11 l-653 0 -11 33 c-31 97 -43 336 -27 533 5 56 6 113 2 128 l-6 26 -194 0 c-211 0 -252 4 -261 28 -12 33 -17 392 -6 522 15 186 -2 174 260 180 115 3 213 8 217 12 4 4 1 52 -5 105 -7 54 -17 130 -22 168 -7 56 -5 91 11 171 10 55 22 130 26 166 4 36 10 72 15 79 7 12 128 15 665 19 l658 5 8 30 c5 18 4 72 -3 130 -12 115 -7 346 11 454 10 61 10 75 -1 82 -8 5 -300 9 -650 9 l-636 0 -27 25 c-18 16 -26 34 -26 57 0 18 -5 87 -10 153 -10 128 5 449 22 472 5 7 26 13 46 15 78 6 1281 3 1287 -4z" />
|
||||
<path d="M1360 1833 c0 -5 -1 -164 -3 -356 l-2 -347 625 -1 c704 -1 708 -1 722 7 5 4 7 20 4 38 -29 141 -32 491 -6 595 9 38 8 45 -7 57 -15 11 -139 13 -675 14 -362 0 -658 -3 -658 -7z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CopilotIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
// SVG sourced from Primer Octicons' copilot-16 icon. GitHub's 2025 brand
|
||||
// guidance deprecated the old standalone Copilot mascot logo.
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="text-current"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.686 1.588-2.065.013-.07.024-.143.036-.218.029-.183.06-.384.126-.612-.201-.508-.254-1.084-.254-1.656 0-.87.128-1.769.693-2.484.579-.733 1.494-1.124 2.724-1.261 1.206-.134 2.262.034 2.944.765.05.053.096.108.139.165.044-.057.094-.112.143-.165.682-.731 1.738-.899 2.944-.765 1.23.137 2.145.528 2.724 1.261.566.715.693 1.614.693 2.484 0 .572-.053 1.148-.254 1.656.066.228.098.429.126.612.012.076.024.148.037.218.924.385 1.522 1.471 1.591 2.095v1.872c0 .766-3.351 3.795-8.002 3.795Zm0-1.485c2.28 0 4.584-1.11 5.002-1.433V7.862l-.023-.116c-.49.21-1.075.291-1.727.291-1.146 0-2.059-.327-2.71-.991A3.222 3.222 0 0 1 8 6.303a3.24 3.24 0 0 1-.544.743c-.65.664-1.563.991-2.71.991-.652 0-1.236-.081-1.727-.291l-.023.116v4.255c.419.323 2.722 1.433 5.002 1.433ZM6.762 2.83c-.193-.206-.637-.413-1.682-.297-1.019.113-1.479.404-1.713.7-.247.312-.369.789-.369 1.554 0 .793.129 1.171.308 1.371.162.181.519.379 1.442.379.853 0 1.339-.235 1.638-.54.315-.322.527-.827.617-1.553.117-.935-.037-1.395-.241-1.614Zm4.155-.297c-1.044-.116-1.488.091-1.681.297-.204.219-.359.679-.242 1.614.091.726.303 1.231.618 1.553.299.305.784.54 1.638.54.922 0 1.28-.198 1.442-.379.179-.2.308-.578.308-1.371 0-.765-.123-1.242-.37-1.554-.233-.296-.693-.587-1.713-.7Z" />
|
||||
<path d="M6.25 9.037a.75.75 0 0 1 .75.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 .75-.75Zm4.25.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 1.5 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentLetterIcon({
|
||||
letter,
|
||||
size = 14
|
||||
}: {
|
||||
letter: string
|
||||
size?: number
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 14 14"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="text-current"
|
||||
>
|
||||
<rect width="14" height="14" rx="3" fill="currentColor" fillOpacity="0.2" />
|
||||
<text
|
||||
x="7"
|
||||
y="10.5"
|
||||
textAnchor="middle"
|
||||
fontSize="8.5"
|
||||
fill="currentColor"
|
||||
fontWeight="700"
|
||||
fontFamily="system-ui, -apple-system, sans-serif"
|
||||
>
|
||||
{letter}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentIcon({
|
||||
agent,
|
||||
size = 14
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import React from 'react'
|
||||
|
||||
export function PiIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
// SVG sourced from pi.dev/favicon.svg — the π shape rendered in currentColor.
|
||||
// Why: className="text-current" opts out of shadcn's Select rule that forces
|
||||
// text-muted-foreground on any <svg> that lacks a text-* class.
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
width={size}
|
||||
viewBox="0 0 800 800"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-current"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
|
||||
/>
|
||||
<path fill="currentColor" d="M517.36 400 H634.72 V634.72 H517.36 Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OmpIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
const gradientId = `${React.useId().replace(/:/g, '')}-omp-gradient`
|
||||
|
||||
// SVG sourced from omp.sh's homepage mark. Why: omp.sh/favicon.svg includes
|
||||
// a dark square background, while the homepage mark is transparent.
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="oklch(0.7 0.24 340)" />
|
||||
<stop offset=".5" stopColor="oklch(0.62 0.21 295)" />
|
||||
<stop offset="1" stopColor="oklch(0.81 0.14 200)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill={`url(#${gradientId})`} d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function KiloIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
// SVG sourced from Kilo-Org/kilocode:packages/kilo-vscode/assets/icons/kilo-light.svg.
|
||||
// Why: the Google favicon for kilo.ai is black-on-black at small sizes and
|
||||
// is illegible. Inlining the brand mark (yellow on black) keeps it readable
|
||||
// on both light and dark app themes without using currentColor — this logo
|
||||
// is intentionally brand-colored, not theme-colored.
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 512 512"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
style={{ borderRadius: 2 }}
|
||||
>
|
||||
<path d="M512 0H0V512H512V0Z" fill="black" />
|
||||
<path
|
||||
d="M322 377H377V421H307.857L278 391.143V322H322V377ZM421 307.857L391.143 278H322V322L377 322V377H421V307.857ZM234 278H190V322H234V278ZM91 391.143L120.857 421H234V377H135V278H91V391.143ZM371.172 189.999V120.856L341.315 90.9995H278V135H327.172V189.999H278V233.999H421V189.999H371.172ZM135 91H91V233.999H135V184.5H190V233.999H234V184.5L190 140.5H135V91ZM234 91H190V140.5H234V91Z"
|
||||
fill="#FAF74F"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiderIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
// SVG sourced from aider.chat/assets/icons/safari-pinned-tab.svg.
|
||||
// Why: className="text-current" opts out of shadcn's Select rule that forces
|
||||
// text-muted-foreground on any <svg> that lacks a text-* class.
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 436 436"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="text-current"
|
||||
>
|
||||
<g transform="translate(0,436) scale(0.1,-0.1)" fill="currentColor" stroke="none">
|
||||
<path d="M0 2180 l0 -2180 2180 0 2180 0 0 2180 0 2180 -2180 0 -2180 0 0 -2180z m2705 1818 c20 -20 28 -121 30 -398 l2 -305 216 -5 c118 -3 218 -8 222 -12 3 -3 10 -46 15 -95 5 -48 16 -126 25 -172 17 -86 17 -81 -17 -233 -14 -67 -13 -365 2 -438 21 -100 22 -159 5 -247 -24 -122 -24 -363 1 -458 23 -88 23 -213 1 -330 -9 -49 -17 -109 -17 -132 l0 -43 203 0 c111 0 208 -4 216 -9 10 -6 18 -51 27 -148 8 -76 16 -152 20 -168 7 -39 -23 -361 -37 -387 -10 -18 -21 -19 -214 -16 -135 2 -208 7 -215 14 -22 22 -33 301 -21 501 6 102 8 189 5 194 -8 13 -417 12 -431 -2 -12 -12 -8 -146 8 -261 8 -55 8 -95 1 -140 -6 -35 -14 -99 -17 -143 -9 -123 -14 -141 -41 -154 -18 -8 -217 -11 -679 -11 l-653 0 -11 33 c-31 97 -43 336 -27 533 5 56 6 113 2 128 l-6 26 -194 0 c-211 0 -252 4 -261 28 -12 33 -17 392 -6 522 15 186 -2 174 260 180 115 3 213 8 217 12 4 4 1 52 -5 105 -7 54 -17 130 -22 168 -7 56 -5 91 11 171 10 55 22 130 26 166 4 36 10 72 15 79 7 12 128 15 665 19 l658 5 8 30 c5 18 4 72 -3 130 -12 115 -7 346 11 454 10 61 10 75 -1 82 -8 5 -300 9 -650 9 l-636 0 -27 25 c-18 16 -26 34 -26 57 0 18 -5 87 -10 153 -10 128 5 449 22 472 5 7 26 13 46 15 78 6 1281 3 1287 -4z" />
|
||||
<path d="M1360 1833 c0 -5 -1 -164 -3 -356 l-2 -347 625 -1 c704 -1 708 -1 722 7 5 4 7 20 4 38 -29 141 -32 491 -6 595 9 38 8 45 -7 57 -15 11 -139 13 -675 14 -362 0 -658 -3 -658 -7z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CopilotIcon({ size = 14 }: { size?: number }): React.JSX.Element {
|
||||
// SVG sourced from Primer Octicons' copilot-16 icon. GitHub's 2025 brand
|
||||
// guidance deprecated the old standalone Copilot mascot logo.
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="text-current"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.686 1.588-2.065.013-.07.024-.143.036-.218.029-.183.06-.384.126-.612-.201-.508-.254-1.084-.254-1.656 0-.87.128-1.769.693-2.484.579-.733 1.494-1.124 2.724-1.261 1.206-.134 2.262.034 2.944.765.05.053.096.108.139.165.044-.057.094-.112.143-.165.682-.731 1.738-.899 2.944-.765 1.23.137 2.145.528 2.724 1.261.566.715.693 1.614.693 2.484 0 .572-.053 1.148-.254 1.656.066.228.098.429.126.612.012.076.024.148.037.218.924.385 1.522 1.471 1.591 2.095v1.872c0 .766-3.351 3.795-8.002 3.795Zm0-1.485c2.28 0 4.584-1.11 5.002-1.433V7.862l-.023-.116c-.49.21-1.075.291-1.727.291-1.146 0-2.059-.327-2.71-.991A3.222 3.222 0 0 1 8 6.303a3.24 3.24 0 0 1-.544.743c-.65.664-1.563.991-2.71.991-.652 0-1.236-.081-1.727-.291l-.023.116v4.255c.419.323 2.722 1.433 5.002 1.433ZM6.762 2.83c-.193-.206-.637-.413-1.682-.297-1.019.113-1.479.404-1.713.7-.247.312-.369.789-.369 1.554 0 .793.129 1.171.308 1.371.162.181.519.379 1.442.379.853 0 1.339-.235 1.638-.54.315-.322.527-.827.617-1.553.117-.935-.037-1.395-.241-1.614Zm4.155-.297c-1.044-.116-1.488.091-1.681.297-.204.219-.359.679-.242 1.614.091.726.303 1.231.618 1.553.299.305.784.54 1.638.54.922 0 1.28-.198 1.442-.379.179-.2.308-.578.308-1.371 0-.765-.123-1.242-.37-1.554-.233-.296-.693-.587-1.713-.7Z" />
|
||||
<path d="M6.25 9.037a.75.75 0 0 1 .75.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 .75-.75Zm4.25.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 1.5 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentLetterIcon({
|
||||
letter,
|
||||
size = 14
|
||||
}: {
|
||||
letter: string
|
||||
size?: number
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 14 14"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="text-current"
|
||||
>
|
||||
<rect width="14" height="14" rx="3" fill="currentColor" fillOpacity="0.2" />
|
||||
<text
|
||||
x="7"
|
||||
y="10.5"
|
||||
textAnchor="middle"
|
||||
fontSize="8.5"
|
||||
fill="currentColor"
|
||||
fontWeight="700"
|
||||
fontFamily="system-ui, -apple-system, sans-serif"
|
||||
>
|
||||
{letter}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user