diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 6322e60dd2d..4f8c5ad9887 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -235,10 +235,19 @@ export async function updateIssue( if (updates.state) { await acquire() try { - const cmd = updates.state === 'closed' ? 'close' : 'reopen' - await ghExecFileAsync(['issue', cmd, String(issueNumber), '--repo', repo], { - ...ghOptions - }) + if (updates.state === 'closed') { + const closeArgs = ['issue', 'close', String(issueNumber), '--repo', repo] + if (updates.stateReason === 'completed') { + closeArgs.push('--reason', 'completed') + } else if (updates.stateReason === 'not_planned') { + closeArgs.push('--reason', 'not planned') + } else if (updates.stateReason === 'duplicate' && updates.duplicateOf) { + closeArgs.push('--duplicate-of', String(updates.duplicateOf)) + } + await ghExecFileAsync(closeArgs, ghOptions) + } else { + await ghExecFileAsync(['issue', 'reopen', String(issueNumber), '--repo', repo], ghOptions) + } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) // Treat "already closed/open" as a no-op diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index 3d3d66757ea..cd72c9e8ce1 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -171,15 +171,29 @@ export async function updateIssueBySlug( if (!args.updates || typeof args.updates !== 'object') { return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } } - const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } = - args.updates + const { + title, + body, + state, + stateReason, + duplicateOf, + addLabels, + removeLabels, + addAssignees, + removeAssignees + } = args.updates // Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}. // Labels/assignees go through their dedicated endpoints. const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` // 1) PATCH body - if (title !== undefined || body !== undefined || state !== undefined) { + if ( + title !== undefined || + body !== undefined || + state !== undefined || + stateReason !== undefined + ) { const patchArgs: string[] = ['-X', 'PATCH', base] if (title !== undefined) { patchArgs.push('--raw-field', `title=${title}`) @@ -190,6 +204,12 @@ export async function updateIssueBySlug( if (state !== undefined) { patchArgs.push('--raw-field', `state=${state}`) } + if (stateReason !== undefined) { + patchArgs.push('--raw-field', `state_reason=${stateReason}`) + } + if (duplicateOf !== undefined) { + patchArgs.push('--raw-field', `duplicate_of=${duplicateOf}`) + } const r = await runRest(patchArgs) if (!r.ok) { return { ok: false, error: r.error } diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 3ae4118970d..612d79e3448 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -96,6 +96,101 @@ background: color-mix(in srgb, var(--muted) 38%, var(--background)); } +.github-markdown-composer-tabbed { + border-radius: 8px; +} + +.github-markdown-composer-tabbar { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 8px; + min-height: 40px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: color-mix(in srgb, var(--muted) 28%, var(--background)); +} + +.github-markdown-composer-tabs { + display: flex; + align-items: stretch; + gap: 0; +} + +.github-markdown-composer-tab { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 72px; + padding: 0 14px; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted-foreground); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.github-markdown-composer-tab:hover { + color: var(--foreground); +} + +.github-markdown-composer-tab.is-active { + margin-bottom: -1px; + border-bottom-color: transparent; + background: var(--background); + color: var(--foreground); +} + +.github-markdown-composer-tabbar-toolbar { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-end; + overflow-x: auto; +} + +.github-markdown-composer-tabbar-toolbar .rich-markdown-editor-toolbar { + min-height: 38px; + padding: 4px 8px; + box-shadow: none; + background: transparent; +} + +.github-markdown-composer-preview { + padding: 12px 14px 18px; +} + +.github-markdown-composer-attachment { + display: flex; + width: 100%; + align-items: center; + gap: 6px; + padding: 8px 12px; + border: none; + border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: transparent; + color: var(--muted-foreground); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.github-markdown-composer-attachment:hover:not(:disabled) { + color: var(--foreground); + background: color-mix(in srgb, var(--accent) 40%, transparent); +} + +.github-markdown-composer-attachment:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.github-issue-comment-composer .github-markdown-composer-tabbed { + border-color: color-mix(in srgb, var(--border) 82%, transparent); +} + .orca-diff-comment-add-btn.rich-markdown-comment-add-btn { display: flex; width: 22px; diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 1ed30f20225..041ef30765f 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -37,8 +37,6 @@ import { Pencil, Plus, RefreshCw, - Send, - Settings, UndoDot, Users, Wrench, @@ -134,6 +132,9 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent' +import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent' +import { GitHubIssueCommentComposer } from '@/components/github/GitHubIssueCommentComposer' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import { getGitHubPRReviewerRows, @@ -2682,12 +2683,12 @@ function ConversationTab({
-
+
{comment.authorAvatarUrl ? ( -
+
- + {translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} {comments.length > 0 && ( @@ -3001,13 +3002,13 @@ function ConversationTab({ )} {comments.length === 0 ? ( -
+

{translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')} -

+

) : visibleComments.length === 0 ? ( -
+

{getPRCommentAudienceEmptyLabel(commentFilter)} -

+

) : (
{visibleCommentGroups.map(renderCommentGroup)} @@ -3017,13 +3018,19 @@ function ConversationTab({ ) : null} {detailsLoaded && repoPath && ( - )}
@@ -4399,39 +4406,6 @@ async function runPullRequestStateUpdate(args: { } } -function GitHubLabelsSettingsLink({ - url, - separated, - onOpen -}: { - url: string | null - separated?: boolean - onOpen?: () => void -}): React.JSX.Element | null { - if (!url) { - return null - } - - return ( -
- -
- ) -} - function GHEditSection({ item, repoPath, @@ -4732,18 +4706,6 @@ function GHEditSection({ return null } - const checkIcon = ( - - - - ) - if (layout === 'sidebar') { return (
@@ -4918,39 +4853,15 @@ function GHEditSection({ className="popover-scroll-content scrollbar-sleek w-60 p-1" align="end" > - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : null} - {!repoLabels.error ? ( -
- {repoLabels.data.map((label) => ( - - ))} -
- ) : null} - 0} - onOpen={() => setLabelPopoverOpen(false)} + setLabelPopoverOpen(false)} /> @@ -5111,39 +5022,15 @@ function GHEditSection({ - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : null} - {!repoLabels.error ? ( -
- {repoLabels.data.map((label) => ( - - ))} -
- ) : null} - 0} - onOpen={() => setLabelPopoverOpen(false)} + setLabelPopoverOpen(false)} />
@@ -5175,41 +5062,14 @@ function GHEditSection({ - {repoAssignees.error ? ( -
- {repoAssignees.error} -
- ) : ( -
- {repoAssignees.data.map((user) => ( - - ))} -
- )} +
@@ -5276,96 +5136,6 @@ function GHEditSection({ ) } -function GHCommentComposer({ - className, - repoPath, - repoId, - issueNumber, - itemType, - onCommentAdded -}: { - className?: string - repoPath: string - repoId?: string | null - issueNumber: number - itemType: 'issue' | 'pr' - onCommentAdded: (comment: PRComment) => void -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const mountedRef = useMountedRef() - - const handleSubmit = useCallback(async () => { - const trimmed = body.trim() - if (!trimmed) { - return - } - setSubmitting(true) - try { - const result = await addIssueCommentForRepo({ - repoPath, - repoId: repoId ?? undefined, - number: issueNumber, - body: trimmed, - type: itemType - }) - if (!mountedRef.current) { - return - } - if (result.ok) { - setBody('') - // Why: use the comment returned by GitHub so the optimistic row shows - // the real login/avatar immediately instead of waiting for a reopen. - onCommentAdded(result.comment) - } else { - toast.error( - result.error ?? - translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') - ) - } - } catch (err) { - if (mountedRef.current) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') - ) - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [body, mountedRef, repoPath, repoId, issueNumber, itemType, onCommentAdded]) - - return ( -
- void handleSubmit()} - /> - -
- ) -} - // Why: the dialog doesn't carry the resolved PR-source slug the Tasks view's // list cache carries, so we reach into workItemsCache to recover it. We scope // the lookup to the dialog's own `repoPath` via the public diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 13a851dc944..a6b919511c4 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -133,6 +133,8 @@ import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent' +import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent' import { getGitHubPRReviewerRows, normalizeGitHubReviewerLogins @@ -200,6 +202,25 @@ function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { } } +function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null { + try { + const parsed = new URL(itemUrl) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return null + } + const segments = parsed.pathname.split('/').filter(Boolean) + if (segments.length < 2) { + return null + } + parsed.pathname = `/${segments[0]}/${segments[1]}/labels` + parsed.search = '' + parsed.hash = '' + return parsed.toString() + } catch { + return null + } +} + const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt')) export type ItemDialogTab = 'conversation' | 'checks' | 'files' @@ -4948,6 +4969,7 @@ function GHEditSection({ const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const { isPending, run } = useImmediateMutation() + const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url]) // Why: when the dialog opens from a Project view, mutations route through // *BySlug IPCs and we must keep `projectViewCache` in sync alongside // `workItemsCache` — `patchWorkItem` only walks the latter, so without this @@ -5188,18 +5210,6 @@ function GHEditSection({ return null } - const checkIcon = ( - - - - ) - return (
{/* State */} @@ -5269,34 +5279,16 @@ function GHEditSection({ - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : ( -
- {repoLabels.data.map((label) => ( - - ))} -
- )} + setLabelPopoverOpen(false)} + />
@@ -5327,41 +5319,14 @@ function GHEditSection({ - {repoAssignees.error ? ( -
- {repoAssignees.error} -
- ) : ( -
- {repoAssignees.data.map((user) => ( - - ))} -
- )} +
diff --git a/src/renderer/src/components/github/CloseReasonDropdown.tsx b/src/renderer/src/components/github/CloseReasonDropdown.tsx new file mode 100644 index 00000000000..6002529cbdf --- /dev/null +++ b/src/renderer/src/components/github/CloseReasonDropdown.tsx @@ -0,0 +1,61 @@ +import { Check, ChevronDown } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { CLOSE_ISSUE_REASONS } from './github-issue-close-reasons' +import type { GitHubIssueCloseReason } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export function CloseReasonDropdown({ + closeReason, + disabled, + onCloseReasonChange +}: { + closeReason: GitHubIssueCloseReason + disabled: boolean + onCloseReasonChange: (reason: GitHubIssueCloseReason) => void +}): React.JSX.Element { + return ( + + + + + + {CLOSE_ISSUE_REASONS.map((option) => ( + onCloseReasonChange(option.reason)}> +
+ {closeReason === option.reason ? ( + + ) : ( + + )} +
+
+ {option.icon} + {option.label} +
+

{option.description}

+
+
+
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx new file mode 100644 index 00000000000..7de32c72d92 --- /dev/null +++ b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx @@ -0,0 +1,369 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { LoaderCircle } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { CLOSE_ISSUE_REASONS } from '@/components/github/github-issue-close-reasons' +import { CloseReasonDropdown } from '@/components/github/CloseReasonDropdown' +import { + addIssueCommentForRepo, + githubAvatarUrl, + runIssueStateUpdate, + type GitHubIssueCommentProjectOrigin +} from '@/components/github/github-issue-comment-helpers' +import { useMountedRef } from '@/hooks/useMountedRef' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import type { + GitHubIssueCloseReason, + GitHubOwnerRepo, + GitHubViewer, + GitHubWorkItem, + PRComment +} from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export function GitHubIssueCommentComposer({ + className, + repoPath, + repoId, + issueNumber, + itemType, + itemState, + itemId, + projectOrigin, + previewGithubRepo, + onCommentAdded, + onStateChange, + onMutated +}: { + className?: string + repoPath: string + repoId?: string | null + issueNumber: number + itemType: 'issue' | 'pr' + itemState?: GitHubWorkItem['state'] + itemId?: string + projectOrigin?: GitHubIssueCommentProjectOrigin + previewGithubRepo?: GitHubOwnerRepo | null + onCommentAdded: (comment: PRComment) => void + onStateChange?: (state: GitHubWorkItem['state']) => void + onMutated?: () => void +}): React.JSX.Element { + const [body, setBody] = useState('') + const [submitting, setSubmitting] = useState(false) + const [statePending, setStatePending] = useState(false) + const [closeReason, setCloseReason] = useState('completed') + const [viewer, setViewer] = useState(null) + const mountedRef = useMountedRef() + const viewerRequestIdRef = useRef(0) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + + useEffect(() => { + const requestId = ++viewerRequestIdRef.current + void window.api.gh + .viewer() + .then((nextViewer) => { + if (mountedRef.current && requestId === viewerRequestIdRef.current) { + setViewer(nextViewer) + } + }) + .catch(() => { + if (mountedRef.current && requestId === viewerRequestIdRef.current) { + setViewer(null) + } + }) + return () => { + viewerRequestIdRef.current += 1 + } + }, [mountedRef]) + + const selectedCloseReason = + CLOSE_ISSUE_REASONS.find((option) => option.reason === closeReason) ?? CLOSE_ISSUE_REASONS[0] + + const canMutateIssueState = + itemType === 'issue' && + itemState !== undefined && + itemState !== 'closed' && + Boolean(onStateChange) && + Boolean(repoPath || projectOrigin) + + const canReopenIssue = + itemType === 'issue' && + itemState === 'closed' && + Boolean(onStateChange) && + Boolean(repoPath || projectOrigin) + + const patchProjectRowIfNeeded = useCallback( + (state: GitHubWorkItem['state']) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state }) + }, + [patchProjectRowContent, projectOrigin] + ) + + const applyStatePatch = useCallback( + (state: GitHubWorkItem['state']) => { + onStateChange?.(state) + if (itemId) { + patchWorkItem(itemId, { state }, repoId ?? undefined) + } + patchProjectRowIfNeeded(state) + }, + [itemId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, repoId] + ) + + const handleSubmit = useCallback(async () => { + const trimmed = body.trim() + if (!trimmed) { + return + } + setSubmitting(true) + try { + const result = await addIssueCommentForRepo({ + repoPath, + repoId: repoId ?? undefined, + number: issueNumber, + body: trimmed, + type: itemType + }) + if (!mountedRef.current) { + return + } + if (result.ok) { + setBody('') + onCommentAdded(result.comment) + } else { + toast.error( + result.error ?? + translate( + 'auto.components.github.GitHubIssueCommentComposer.082515176a', + 'Failed to add comment' + ) + ) + } + } catch (err) { + if (mountedRef.current) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.github.GitHubIssueCommentComposer.082515176a', + 'Failed to add comment' + ) + ) + } + } finally { + if (mountedRef.current) { + setSubmitting(false) + } + } + }, [body, issueNumber, itemType, mountedRef, onCommentAdded, repoId, repoPath]) + + const handleCloseIssue = useCallback( + async (reason: GitHubIssueCloseReason = closeReason) => { + if (!canMutateIssueState || statePending) { + return + } + const previousState = itemState ?? 'open' + setStatePending(true) + applyStatePatch('closed') + try { + await runIssueStateUpdate({ + repoPath, + repoId, + projectOrigin, + number: issueNumber, + updates: { state: 'closed', stateReason: reason } + }) + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + translate('auto.components.github.GitHubIssueCommentComposer.9f88657c4e', 'Issue closed') + ) + onMutated?.() + } catch (err) { + applyStatePatch(previousState) + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.github.GitHubIssueCommentComposer.e9b7cb7d17', + 'Failed to close issue' + ) + ) + } finally { + if (mountedRef.current) { + setStatePending(false) + } + } + }, + [ + applyStatePatch, + canMutateIssueState, + closeReason, + issueNumber, + itemState, + mountedRef, + onMutated, + projectOrigin, + repoId, + repoPath, + statePending + ] + ) + + const handleReopenIssue = useCallback(async () => { + if (!canReopenIssue || statePending) { + return + } + const previousState = itemState ?? 'closed' + setStatePending(true) + applyStatePatch('open') + try { + await runIssueStateUpdate({ + repoPath, + repoId, + projectOrigin, + number: issueNumber, + updates: { state: 'open' } + }) + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + translate('auto.components.github.GitHubIssueCommentComposer.bd3b4492a0', 'Issue reopened') + ) + onMutated?.() + } catch (err) { + applyStatePatch(previousState) + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.github.GitHubIssueCommentComposer.f2a8c1d903', + 'Failed to reopen issue' + ) + ) + } finally { + if (mountedRef.current) { + setStatePending(false) + } + } + }, [ + applyStatePatch, + canReopenIssue, + issueNumber, + itemState, + mountedRef, + onMutated, + projectOrigin, + repoId, + repoPath, + statePending + ]) + + const avatar = viewer?.login ? ( + {viewer.login} + ) : ( +
+ ) + + return ( +
+
+ {avatar} +
+

+ {translate( + 'auto.components.github.GitHubIssueCommentComposer.a1b2c3d4e5', + 'Add a comment' + )} +

+ void handleSubmit()} + /> +
+ {canMutateIssueState ? ( + + + { + setCloseReason(reason) + void handleCloseIssue(reason) + }} + /> + + ) : null} + {canReopenIssue ? ( + + ) : null} + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index 3c64fbe6f33..df4aaf6236c 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { EditorContent, useEditor } from '@tiptap/react' import type { Editor } from '@tiptap/react' import Placeholder from '@tiptap/extension-placeholder' -import { ImageIcon } from 'lucide-react' -import { toast } from 'sonner' +import { ImageIcon, Paperclip } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' @@ -17,6 +16,13 @@ import { } from '@/components/editor/RichMarkdownLinkBubble' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' import { normalizeSoftBreaks } from '@/components/editor/rich-markdown-normalize' +import { GitHubMarkdownComposerPreviewPane } from '@/components/github/github-markdown-composer-preview-pane' +import { + GitHubMarkdownComposerTabbar, + type ComposerTab +} from '@/components/github/github-markdown-composer-tabbar' +import { useImageInput } from '@/components/github/use-image-input' +import type { GitHubOwnerRepo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' type GitHubMarkdownComposerProps = { @@ -28,15 +34,8 @@ type GitHubMarkdownComposerProps = { disabled?: boolean autoFocus?: boolean onSubmitShortcut?: () => void -} - -function isHttpImageUrl(value: string): boolean { - try { - const parsed = new URL(value) - return parsed.protocol === 'https:' || parsed.protocol === 'http:' - } catch { - return false - } + layout?: 'stacked' | 'tabbed' + previewGithubRepo?: GitHubOwnerRepo | null } export function GitHubMarkdownComposer({ @@ -47,7 +46,9 @@ export function GitHubMarkdownComposer({ className, disabled = false, autoFocus = false, - onSubmitShortcut + onSubmitShortcut, + layout = 'stacked', + previewGithubRepo = null }: GitHubMarkdownComposerProps): React.JSX.Element { const rootRef = useRef(null) const editorRef = useRef(null) @@ -57,18 +58,25 @@ export function GitHubMarkdownComposer({ const onSubmitShortcutRef = useRef(onSubmitShortcut) const disabledRef = useRef(disabled) const isEditingLinkRef = useRef(false) - const imageInputOpenRef = useRef(false) + const [activeTab, setActiveTab] = useState('write') const [linkBubble, setLinkBubble] = useState(null) const [isEditingLink, setIsEditingLink] = useState(false) - const [imageInputOpen, setImageInputOpen] = useState(false) - const [imageUrl, setImageUrl] = useState('') - const imageInputRef = useRef(null) + const isTabbed = layout === 'tabbed' + + const { + imageUrl, + imageInputOpen, + imageInputRef, + openImagePicker, + setImageUrl, + setImageInputOpen, + insertImageUrl + } = useImageInput(editorRef, disabledRef, () => setActiveTab('write')) onChangeRef.current = onChange onSubmitShortcutRef.current = onSubmitShortcut disabledRef.current = disabled isEditingLinkRef.current = isEditingLink - imageInputOpenRef.current = imageInputOpen const extensions = useMemo( () => [ @@ -125,7 +133,7 @@ export function GitHubMarkdownComposer({ openLinkEditor() return true } - if (event.key === 'Escape' && imageInputOpenRef.current) { + if (event.key === 'Escape' && imageInputOpen) { event.preventDefault() event.stopPropagation() setImageInputOpen(false) @@ -183,6 +191,23 @@ export function GitHubMarkdownComposer({ if (!editor) { return } + // Why: parent clears to '' after submit; always reset the editor so stale + // draft text never survives a successful comment post. + if (!value.trim()) { + if (editor.getMarkdown().trim()) { + applyingExternalValueRef.current = true + try { + editor.commands.clearContent(true) + normalizeSoftBreaks(editor) + lastSyncedMarkdownRef.current = '' + } finally { + applyingExternalValueRef.current = false + } + } else { + lastSyncedMarkdownRef.current = '' + } + return + } if (value === lastSyncedMarkdownRef.current || value === editor.getMarkdown()) { lastSyncedMarkdownRef.current = value return @@ -200,12 +225,6 @@ export function GitHubMarkdownComposer({ } }, [editor, value]) - useEffect(() => { - if (imageInputOpen) { - requestAnimationFrame(() => imageInputRef.current?.focus()) - } - }, [imageInputOpen]) - const handleLinkSave = useCallback((href: string) => { const editor = editorRef.current if (!editor) { @@ -249,92 +268,107 @@ export function GitHubMarkdownComposer({ } }, [linkBubble?.href]) - const insertImageUrl = useCallback(() => { - const editor = editorRef.current - const trimmed = imageUrl.trim() - if (!editor || !trimmed) { - return - } - if (!isHttpImageUrl(trimmed)) { - toast.error( - translate( - 'auto.components.github.GitHubMarkdownComposer.ec6310b731', - 'Use an http:// or https:// image URL.' - ) - ) - return - } - editor - .chain() - .focus() - .insertContent({ type: 'image', attrs: { src: trimmed } }) - .run() - setImageUrl('') - setImageInputOpen(false) - }, [imageUrl]) + const toolbar = ( + + ) + + const imageInputRow = imageInputOpen ? ( +
{ + event.preventDefault() + insertImageUrl() + }} + > + + setImageUrl(event.target.value)} + onKeyDown={(event) => { + if (isScreenSubmitShortcut(event)) { + event.preventDefault() + event.stopPropagation() + insertImageUrl() + return + } + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + setImageInputOpen(false) + } + }} + placeholder={translate( + 'auto.components.github.GitHubMarkdownComposer.f24783f470', + 'https://...' + )} + disabled={disabled} + className="h-8 min-w-0 text-xs" + /> + + + + ) : null + + const editorPane = ( +
+ +
+ ) + + const previewPane = ( + + ) + + const attachmentFooter = isTabbed ? ( + + ) : null return (
- { - if (!disabledRef.current) { - setImageInputOpen(true) - } - }} - /> - {imageInputOpen ? ( -
{ - event.preventDefault() - insertImageUrl() - }} - > - - setImageUrl(event.target.value)} - onKeyDown={(event) => { - if (isScreenSubmitShortcut(event)) { - event.preventDefault() - event.stopPropagation() - insertImageUrl() - return - } - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - setImageInputOpen(false) - } - }} - placeholder={translate( - 'auto.components.github.GitHubMarkdownComposer.f24783f470', - 'https://...' - )} - disabled={disabled} - className="h-8 min-w-0 text-xs" - /> - - - - ) : null} -
- -
+ {isTabbed ? ( + + {toolbar} + + ) : ( + toolbar + )} + {imageInputRow} + {isTabbed ? (activeTab === 'write' ? editorPane : previewPane) : editorPane} + {attachmentFooter} {linkBubble ? ( + + +) + +export function GitHubWorkItemAssigneePopoverContent({ + open, + assignees, + selectedLogins, + error, + loading, + onToggleAssignee +}: { + open: boolean + assignees: readonly GitHubAssignableUser[] + selectedLogins: readonly string[] + error: string | null | undefined + loading?: boolean + onToggleAssignee: (login: string) => void +}): React.JSX.Element { + const [query, setQuery] = useState('') + const filteredAssignees = useMemo( + () => filterGitHubWorkItemAssignees(assignees, query), + [assignees, query] + ) + const selectedSet = useMemo(() => new Set(selectedLogins), [selectedLogins]) + + useEffect(() => { + if (!open) { + setQuery('') + } + }, [open]) + + if (error) { + return
{error}
+ } + + const emptyText = loading + ? translate( + 'auto.components.github.GitHubWorkItemAssigneePopoverContent.cddd9b04a7', + 'Loading assignees' + ) + : translate( + 'auto.components.github.GitHubWorkItemAssigneePopoverContent.a00830d3f7', + 'No users' + ) + + return ( + + + + + {emptyText} + + {filteredAssignees.map((user) => { + const isSelected = selectedSet.has(user.login) + return ( + onToggleAssignee(user.login)} + className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]" + > + + {isSelected ? assigneeCheckIcon : null} + + + {user.login} + {user.name ? ( + + {user.name} + + ) : null} + + + ) + })} + + + ) +} diff --git a/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx b/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx new file mode 100644 index 00000000000..ff0612e86bb --- /dev/null +++ b/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { ExternalLink, Settings } from 'lucide-react' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { filterGitHubWorkItemLabels } from './github-work-item-label-filter' + +const labelCheckIcon = ( + + + +) + +function GitHubLabelsSettingsLink({ + url, + separated, + onOpen +}: { + url: string | null + separated?: boolean + onOpen?: () => void +}): React.JSX.Element | null { + if (!url) { + return null + } + + return ( +
+ +
+ ) +} + +export function GitHubWorkItemLabelPopoverContent({ + open, + labels, + selectedLabels, + error, + loading, + repositoryLabelsUrl, + onToggleLabel, + onOpenSettingsLink +}: { + open: boolean + labels: readonly string[] + selectedLabels: readonly string[] + error: string | null | undefined + loading?: boolean + repositoryLabelsUrl?: string | null + onToggleLabel: (label: string) => void + onOpenSettingsLink?: () => void +}): React.JSX.Element { + const [query, setQuery] = useState('') + const filteredLabels = useMemo(() => filterGitHubWorkItemLabels(labels, query), [labels, query]) + const selectedSet = useMemo(() => new Set(selectedLabels), [selectedLabels]) + + useEffect(() => { + if (!open) { + setQuery('') + } + }, [open]) + + if (error) { + return
{error}
+ } + + const emptyText = loading + ? translate( + 'auto.components.github.GitHubWorkItemLabelPopoverContent.cddd9b04a7', + 'Loading labels' + ) + : translate('auto.components.github.GitHubWorkItemLabelPopoverContent.de26e2eb06', 'No labels') + + return ( + <> + + + + + {emptyText} + + {filteredLabels.map((label) => { + const isSelected = selectedSet.has(label) + return ( + onToggleLabel(label)} + className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]" + > + + {isSelected ? labelCheckIcon : null} + + {label} + + ) + })} + + + 0} + onOpen={onOpenSettingsLink} + /> + + ) +} diff --git a/src/renderer/src/components/github/github-issue-close-reasons.tsx b/src/renderer/src/components/github/github-issue-close-reasons.tsx new file mode 100644 index 00000000000..c9034a1cbb0 --- /dev/null +++ b/src/renderer/src/components/github/github-issue-close-reasons.tsx @@ -0,0 +1,49 @@ +import { Ban, CheckCircle2, Copy } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { GitHubIssueCloseReason } from '../../../../shared/types' + +export type CloseIssueReasonOption = { + reason: GitHubIssueCloseReason + label: string + description: string + icon: React.JSX.Element +} + +export const CLOSE_ISSUE_REASONS: CloseIssueReasonOption[] = [ + { + reason: 'completed', + label: translate( + 'auto.components.github.githubIssueCloseReasons.completed.label', + 'Close as completed' + ), + description: translate( + 'auto.components.github.githubIssueCloseReasons.completed.description', + 'Done, closed, fixed, resolved' + ), + icon: + }, + { + reason: 'not_planned', + label: translate( + 'auto.components.github.githubIssueCloseReasons.notPlanned.label', + 'Close as not planned' + ), + description: translate( + 'auto.components.github.githubIssueCloseReasons.notPlanned.description', + "Won't fix, can't repro, stale" + ), + icon: + }, + { + reason: 'duplicate', + label: translate( + 'auto.components.github.githubIssueCloseReasons.duplicate.label', + 'Close as duplicate' + ), + description: translate( + 'auto.components.github.githubIssueCloseReasons.duplicate.description', + 'Duplicate of another issue' + ), + icon: + } +] diff --git a/src/renderer/src/components/github/github-issue-comment-helpers.ts b/src/renderer/src/components/github/github-issue-comment-helpers.ts new file mode 100644 index 00000000000..6bb52ea9b60 --- /dev/null +++ b/src/renderer/src/components/github/github-issue-comment-helpers.ts @@ -0,0 +1,74 @@ +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { GitHubIssueCloseReason } from '../../../../shared/types' + +export type GitHubIssueCommentProjectOrigin = { + owner: string + repo: string + cacheKey: string + projectItemId: string +} + +export async function runIssueStateUpdate(args: { + repoPath: string + repoId?: string | null + projectOrigin: GitHubIssueCommentProjectOrigin | undefined + number: number + updates: { + state: 'open' | 'closed' + stateReason?: GitHubIssueCloseReason + duplicateOf?: number + } +}): Promise { + if (args.projectOrigin) { + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const updateArgs = { + owner: args.projectOrigin.owner, + repo: args.projectOrigin.repo, + number: args.number, + updates: args.updates + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.project.updateIssueBySlug', + updateArgs, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueBySlug(updateArgs) + if (!res.ok) { + throw new Error(res.error.message) + } + return + } + const res = await window.api.gh.updateIssue({ + repoPath: args.repoPath, + repoId: args.repoId ?? undefined, + number: args.number, + updates: args.updates + }) + if (!res.ok) { + throw new Error(res.error) + } +} + +export async function addIssueCommentForRepo(args: { + repoId?: string + repoPath: string + number: number + body: string + type?: 'issue' | 'pr' +}): Promise>> { + return window.api.gh.addIssueComment({ + repoPath: args.repoPath, + repoId: args.repoId, + number: args.number, + body: args.body, + type: args.type + }) +} + +export function githubAvatarUrl(login: string): string { + return `https://github.com/${encodeURIComponent(login)}.png?size=64` +} diff --git a/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx b/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx new file mode 100644 index 00000000000..06739d438b7 --- /dev/null +++ b/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx @@ -0,0 +1,44 @@ +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import type { GitHubOwnerRepo } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export function isHttpImageUrl(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' + } catch { + return false + } +} + +export function GitHubMarkdownComposerPreviewPane({ + value, + minHeightClassName, + previewGithubRepo +}: { + value: string + minHeightClassName: string + previewGithubRepo: GitHubOwnerRepo | null +}): React.JSX.Element { + return ( +
+ {value.trim() ? ( + + ) : ( +

+ {translate( + 'auto.components.github.GitHubMarkdownComposer.8f1c2d4e6a', + 'Nothing to preview' + )} +

+ )} +
+ ) +} diff --git a/src/renderer/src/components/github/github-markdown-composer-tabbar.tsx b/src/renderer/src/components/github/github-markdown-composer-tabbar.tsx new file mode 100644 index 00000000000..2436cb970a7 --- /dev/null +++ b/src/renderer/src/components/github/github-markdown-composer-tabbar.tsx @@ -0,0 +1,43 @@ +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { ReactNode } from 'react' + +export type ComposerTab = 'write' | 'preview' + +export function GitHubMarkdownComposerTabbar({ + activeTab, + onTabChange, + children +}: { + activeTab: ComposerTab + onTabChange: (tab: ComposerTab) => void + children: ReactNode +}): React.JSX.Element { + return ( +
+
+ + +
+ {activeTab === 'write' ? ( +
{children}
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/github/github-work-item-assignee-filter.test.ts b/src/renderer/src/components/github/github-work-item-assignee-filter.test.ts new file mode 100644 index 00000000000..eb3e340729f --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-assignee-filter.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { filterGitHubWorkItemAssignees } from './github-work-item-assignee-filter' + +describe('filterGitHubWorkItemAssignees', () => { + const assignees = [ + { login: 'alice', name: 'Alice Smith', avatarUrl: 'https://example.com/alice.png' }, + { login: 'bob', name: 'Bob Jones', avatarUrl: 'https://example.com/bob.png' }, + { login: 'carol', name: null, avatarUrl: 'https://example.com/carol.png' } + ] + + it('returns all assignees when the query is empty', () => { + expect(filterGitHubWorkItemAssignees(assignees, '')).toEqual(assignees) + expect(filterGitHubWorkItemAssignees(assignees, ' ')).toEqual(assignees) + }) + + it('matches logins case-insensitively', () => { + expect(filterGitHubWorkItemAssignees(assignees, 'BOB')).toEqual([assignees[1]]) + }) + + it('matches display names case-insensitively', () => { + expect(filterGitHubWorkItemAssignees(assignees, 'smith')).toEqual([assignees[0]]) + expect(filterGitHubWorkItemAssignees(assignees, 'jones')).toEqual([assignees[1]]) + }) +}) diff --git a/src/renderer/src/components/github/github-work-item-assignee-filter.ts b/src/renderer/src/components/github/github-work-item-assignee-filter.ts new file mode 100644 index 00000000000..6682321f24e --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-assignee-filter.ts @@ -0,0 +1,16 @@ +import type { GitHubAssignableUser } from '../../../../shared/types' + +export function filterGitHubWorkItemAssignees( + assignees: readonly GitHubAssignableUser[], + query: string +): GitHubAssignableUser[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return [...assignees] + } + return assignees.filter( + (user) => + user.login.toLowerCase().includes(normalizedQuery) || + (user.name ?? '').toLowerCase().includes(normalizedQuery) + ) +} diff --git a/src/renderer/src/components/github/github-work-item-label-filter.test.ts b/src/renderer/src/components/github/github-work-item-label-filter.test.ts new file mode 100644 index 00000000000..7f1adad477c --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-label-filter.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { filterGitHubWorkItemLabels } from './github-work-item-label-filter' + +describe('filterGitHubWorkItemLabels', () => { + const labels = ['agent-workflow', 'bug', 'documentation', 'duplicate'] + + it('returns all labels when the query is empty', () => { + expect(filterGitHubWorkItemLabels(labels, '')).toEqual(labels) + expect(filterGitHubWorkItemLabels(labels, ' ')).toEqual(labels) + }) + + it('matches labels case-insensitively', () => { + expect(filterGitHubWorkItemLabels(labels, 'BUG')).toEqual(['bug']) + expect(filterGitHubWorkItemLabels(labels, 'Doc')).toEqual(['documentation']) + }) + + it('matches partial label names', () => { + expect(filterGitHubWorkItemLabels(labels, 'agent')).toEqual(['agent-workflow']) + expect(filterGitHubWorkItemLabels(labels, 'dup')).toEqual(['duplicate']) + }) +}) diff --git a/src/renderer/src/components/github/github-work-item-label-filter.ts b/src/renderer/src/components/github/github-work-item-label-filter.ts new file mode 100644 index 00000000000..65f6b27b60c --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-label-filter.ts @@ -0,0 +1,7 @@ +export function filterGitHubWorkItemLabels(labels: readonly string[], query: string): string[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return [...labels] + } + return labels.filter((label) => label.toLowerCase().includes(normalizedQuery)) +} diff --git a/src/renderer/src/components/github/use-image-input.ts b/src/renderer/src/components/github/use-image-input.ts new file mode 100644 index 00000000000..ab6d5e11a15 --- /dev/null +++ b/src/renderer/src/components/github/use-image-input.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import type { Editor } from '@tiptap/react' +import { isHttpImageUrl } from './github-markdown-composer-preview-pane' +import { translate } from '@/i18n/i18n' + +export function useImageInput( + editorRef: React.MutableRefObject, + disabledRef: React.MutableRefObject, + onOpen?: () => void +): { + imageUrl: string + imageInputOpen: boolean + imageInputRef: React.RefObject + openImagePicker: () => void + setImageUrl: (value: string) => void + setImageInputOpen: (open: boolean) => void + insertImageUrl: () => void +} { + const [imageInputOpen, setImageInputOpen] = useState(false) + const [imageUrl, setImageUrl] = useState('') + const imageInputRef = useRef(null) + + useEffect(() => { + if (imageInputOpen) { + requestAnimationFrame(() => imageInputRef.current?.focus()) + } + }, [imageInputOpen]) + + const insertImageUrl = useCallback(() => { + const editor = editorRef.current + const trimmed = imageUrl.trim() + if (!editor || !trimmed) { + return + } + if (!isHttpImageUrl(trimmed)) { + toast.error( + translate( + 'auto.components.github.GitHubMarkdownComposer.ec6310b731', + 'Use an http:// or https:// image URL.' + ) + ) + return + } + editor + .chain() + .focus() + .insertContent({ type: 'image', attrs: { src: trimmed } }) + .run() + setImageUrl('') + setImageInputOpen(false) + }, [imageUrl, editorRef]) + + const openImagePicker = useCallback(() => { + if (!disabledRef.current) { + setImageInputOpen(true) + onOpen?.() + } + }, [disabledRef, onOpen]) + + return { + imageUrl, + imageInputOpen, + imageInputRef, + openImagePicker, + setImageUrl, + setImageInputOpen, + insertImageUrl + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 59d81ba2579..4d26adf5305 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -463,6 +463,35 @@ "7d732521ec": "Comment" } } + }, + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + } + } } }, "hooks": { @@ -1775,7 +1804,11 @@ "015b4e607d": "Cancel", "e3bd59143c": "Insert", "f24783f470": "https://...", - "ec6310b731": "Use an http:// or https:// image URL." + "ec6310b731": "Use an http:// or https:// image URL.", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "Showing issues from", @@ -1849,6 +1882,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 65cbbcee527..f365b395192 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -463,6 +463,35 @@ "7d732521ec": "Comentario" } } + }, + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + } + } } }, "hooks": { @@ -1775,7 +1804,11 @@ "015b4e607d": "Cancelar", "e3bd59143c": "Insertar", "f24783f470": "https://...", - "ec6310b731": "Utilice una URL de imagen http:// o https://." + "ec6310b731": "Utilice una URL de imagen http:// o https://.", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "Mostrando problemas de", @@ -1849,6 +1882,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 888ce576bb0..af80ad64554 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -463,6 +463,35 @@ "7d732521ec": "コメント" } } + }, + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + } + } } }, "hooks": { @@ -1775,7 +1804,11 @@ "015b4e607d": "キャンセル", "e3bd59143c": "入れる", "f24783f470": "https://...", - "ec6310b731": "http:// または https:// 画像 URL を使用します。" + "ec6310b731": "http:// または https:// 画像 URL を使用します。", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "からのイシューを表示しています", @@ -1849,6 +1882,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8b34556d89c..378cbbd42d8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -463,6 +463,35 @@ "7d732521ec": "댓글" } } + }, + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + } + } } }, "hooks": { @@ -1775,7 +1804,11 @@ "015b4e607d": "취소", "e3bd59143c": "끼워 넣다", "f24783f470": "https://...", - "ec6310b731": "http:// 또는 https:// 이미지 URL을 사용하세요." + "ec6310b731": "http:// 또는 https:// 이미지 URL을 사용하세요.", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "다음에서 이슈 표시", @@ -1849,6 +1882,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 50187293a2e..93c92b05708 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -463,6 +463,35 @@ "7d732521ec": "评论" } } + }, + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + } + } } }, "hooks": { @@ -1775,7 +1804,11 @@ "015b4e607d": "取消", "e3bd59143c": "插入", "f24783f470": "https://...", - "ec6310b731": "使用 http:// 或 https:// 图片 URL。" + "ec6310b731": "使用 http:// 或 https:// 图片 URL。", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "显示议题来自", @@ -1849,6 +1882,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { diff --git a/src/renderer/src/lib/folder-workspace-path-status.ts b/src/renderer/src/lib/folder-workspace-path-status.ts index 0a2eda79f9f..51f2807d6cc 100644 --- a/src/renderer/src/lib/folder-workspace-path-status.ts +++ b/src/renderer/src/lib/folder-workspace-path-status.ts @@ -1,3 +1,4 @@ +import { translate } from '@/i18n/i18n' import type { FolderWorkspacePathStatus } from '../../../shared/folder-workspace-path-status' import { blocksFolderWorkspaceActivation } from '../../../shared/folder-workspace-path-status' @@ -9,14 +10,23 @@ export function getFolderWorkspacePathStatusTitle( } switch (status.reason) { case 'missing': - return 'Folder not found' + return translate('auto.lib.folderWorkspacePathStatus.title.missing', 'Folder not found') case 'not-directory': - return 'Path is not a folder' + return translate( + 'auto.lib.folderWorkspacePathStatus.title.notDirectory', + 'Path is not a folder' + ) case 'ambiguous-connection': - return 'Cannot determine connection' + return translate( + 'auto.lib.folderWorkspacePathStatus.title.ambiguousConnection', + 'Cannot determine connection' + ) + case undefined: case 'unavailable': - default: - return 'Cannot check folder' + return translate( + 'auto.lib.folderWorkspacePathStatus.title.unavailable', + 'Cannot check folder' + ) } } @@ -28,14 +38,28 @@ export function getFolderWorkspacePathStatusDescription( } switch (status.reason) { case 'missing': - return `Orca cannot find ${status.path}. Remove and re-import this folder workspace.` + return translate( + 'auto.lib.folderWorkspacePathStatus.description.missing', + 'Orca cannot find {{path}}. Remove and re-import this folder workspace.', + { path: status.path } + ) case 'not-directory': - return `${status.path} exists, but it is not a folder.` + return translate( + 'auto.lib.folderWorkspacePathStatus.description.notDirectory', + '{{path}} exists, but it is not a folder.', + { path: status.path } + ) case 'ambiguous-connection': - return 'Orca cannot tell which SSH connection owns this folder scope.' + return translate( + 'auto.lib.folderWorkspacePathStatus.description.ambiguousConnection', + 'Orca cannot tell which SSH connection owns this folder scope.' + ) + case undefined: case 'unavailable': - default: - return 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' + return translate( + 'auto.lib.folderWorkspacePathStatus.description.unavailable', + 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' + ) } } @@ -47,30 +71,61 @@ export function formatFolderWorkspaceCreateError(error: unknown): { const path = message.includes(':') ? message.slice(message.indexOf(':') + 1) : '' if (message.startsWith('folder_workspace_path_missing:')) { return { - title: 'Folder not found', - description: `Orca cannot find ${path}. Remove and re-import the folder.` + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.missing', + 'Folder not found' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.missing', + 'Orca cannot find {{path}}. Remove and re-import the folder.', + { path } + ) } } if (message.startsWith('folder_workspace_path_not_directory:')) { return { - title: 'Path is not a folder', - description: `${path} exists, but it is not a folder.` + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.notDirectory', + 'Path is not a folder' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.notDirectory', + '{{path}} exists, but it is not a folder.', + { path } + ) } } if (message.startsWith('folder_workspace_connection_ambiguous:')) { return { - title: 'Cannot determine connection', - description: 'Orca cannot tell which SSH connection owns this folder scope.' + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.ambiguousConnection', + 'Cannot determine connection' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.ambiguousConnection', + 'Orca cannot tell which SSH connection owns this folder scope.' + ) } } if (message.startsWith('folder_workspace_path_unavailable:')) { return { - title: 'Cannot check folder', - description: + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.unavailable', + 'Cannot check folder' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.unavailable', 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' + ) } } - return { title: 'Failed to create folder workspace', description: message } + return { + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.generic', + 'Failed to create folder workspace' + ), + description: message + } } export function folderWorkspaceActivationBlocked( diff --git a/src/shared/types.ts b/src/shared/types.ts index cde79b12f7e..90dc0bb4bfb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1382,8 +1382,12 @@ export type GitHubCreateIssueFields = { assignees?: string[] } +export type GitHubIssueCloseReason = 'completed' | 'not_planned' | 'duplicate' + export type GitHubIssueUpdate = { state?: 'open' | 'closed' + stateReason?: GitHubIssueCloseReason + duplicateOf?: number title?: string // Why: body writes use the REST issue endpoint instead of `gh issue edit` // because that command does not consistently cover every body-edit case the