mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Add Source Control split-open modifier clicks
This commit is contained in:
@@ -178,6 +178,10 @@ import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../share
|
||||
import { getRuntimeGitScope } from '@/runtime/runtime-git-client'
|
||||
import { getRepositorySourceControlAiSectionId } from '@/components/settings/repository-settings-targets'
|
||||
import { hasExpandedCommitFailureDetails, summarizeCommitFailure } from './commit-failure-summary'
|
||||
import {
|
||||
isSourceControlSplitOpenModifier,
|
||||
type SourceControlRowOpenEvent
|
||||
} from './source-control-split-open'
|
||||
|
||||
export type SourceControlScope = 'all' | 'uncommitted'
|
||||
type AbortConflictOperation = Extract<GitConflictOperation, 'merge' | 'rebase'>
|
||||
@@ -895,6 +899,7 @@ export function HostedReviewHeaderLink({
|
||||
|
||||
function SourceControlInner(): React.JSX.Element {
|
||||
const sourceControlRef = useRef<HTMLDivElement>(null)
|
||||
const isMac = useMemo(() => navigator.userAgent.includes('Mac'), [])
|
||||
// Why: React setState is async, so a rapid double-click on the Commit
|
||||
// button can both pass the isCommitting state guard before the disabled
|
||||
// state re-renders. A ref flipped synchronously at the start of
|
||||
@@ -967,6 +972,9 @@ function SourceControlInner(): React.JSX.Element {
|
||||
const openConflictFile = useAppStore((s) => s.openConflictFile)
|
||||
const openConflictReview = useAppStore((s) => s.openConflictReview)
|
||||
const openBranchDiff = useAppStore((s) => s.openBranchDiff)
|
||||
const createEmptySplitGroup = useAppStore((s) => s.createEmptySplitGroup)
|
||||
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
|
||||
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
|
||||
const openAllDiffs = useAppStore((s) => s.openAllDiffs)
|
||||
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
|
||||
const openCommitAllDiffs = useAppStore((s) => s.openCommitAllDiffs)
|
||||
@@ -2824,16 +2832,36 @@ function SourceControlInner(): React.JSX.Element {
|
||||
]
|
||||
)
|
||||
|
||||
// Why: modifier-click should keep the current pane intact by opening the
|
||||
// selected Source Control file in a fresh split to the right.
|
||||
const resolveSplitTargetGroupId = useCallback(
|
||||
(event?: SourceControlRowOpenEvent): string | undefined => {
|
||||
if (!event || !activeWorktreeId || !isSourceControlSplitOpenModifier(event, isMac)) {
|
||||
return undefined
|
||||
}
|
||||
const sourceGroupId =
|
||||
activeGroupIdByWorktree[activeWorktreeId] ?? groupsByWorktree[activeWorktreeId]?.[0]?.id
|
||||
if (!sourceGroupId) {
|
||||
return undefined
|
||||
}
|
||||
return createEmptySplitGroup(activeWorktreeId, sourceGroupId, 'right') ?? undefined
|
||||
},
|
||||
[activeGroupIdByWorktree, activeWorktreeId, createEmptySplitGroup, groupsByWorktree, isMac]
|
||||
)
|
||||
|
||||
const handleOpenDiff = useCallback(
|
||||
(entry: GitStatusEntry) => {
|
||||
(entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => {
|
||||
if (!activeWorktreeId || !worktreePath) {
|
||||
return
|
||||
}
|
||||
const targetGroupId = resolveSplitTargetGroupId(event)
|
||||
if (entry.conflictKind && entry.conflictStatus) {
|
||||
if (entry.conflictStatus === 'unresolved') {
|
||||
trackConflictPath(activeWorktreeId, entry.path, entry.conflictKind)
|
||||
}
|
||||
openConflictFile(activeWorktreeId, worktreePath, entry, detectLanguage(entry.path))
|
||||
openConflictFile(activeWorktreeId, worktreePath, entry, detectLanguage(entry.path), {
|
||||
targetGroupId
|
||||
})
|
||||
return
|
||||
}
|
||||
const language = detectLanguage(entry.path)
|
||||
@@ -2847,21 +2875,27 @@ function SourceControlInner(): React.JSX.Element {
|
||||
// files keep the existing diff-tab flow until the diff-tab type is
|
||||
// eventually collapsed (see reviews/changes-view-mode-plan.md §"Follow-up").
|
||||
if (language === 'markdown' && entry.area === 'unstaged') {
|
||||
openFile({
|
||||
filePath,
|
||||
relativePath: entry.path,
|
||||
worktreeId: activeWorktreeId,
|
||||
language,
|
||||
mode: 'edit'
|
||||
})
|
||||
openFile(
|
||||
{
|
||||
filePath,
|
||||
relativePath: entry.path,
|
||||
worktreeId: activeWorktreeId,
|
||||
language,
|
||||
mode: 'edit'
|
||||
},
|
||||
{ targetGroupId }
|
||||
)
|
||||
setEditorViewMode(filePath, 'changes')
|
||||
return
|
||||
}
|
||||
openDiff(activeWorktreeId, filePath, entry.path, language, entry.area === 'staged')
|
||||
openDiff(activeWorktreeId, filePath, entry.path, language, entry.area === 'staged', {
|
||||
targetGroupId
|
||||
})
|
||||
},
|
||||
[
|
||||
activeWorktreeId,
|
||||
worktreePath,
|
||||
resolveSplitTargetGroupId,
|
||||
trackConflictPath,
|
||||
openConflictFile,
|
||||
openDiff,
|
||||
@@ -2874,6 +2908,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
useSourceControlSelection({
|
||||
flatEntries: visibleSelectionEntries,
|
||||
onOpenDiff: handleOpenDiff,
|
||||
shouldOpenAsSplit: (event) => isSourceControlSplitOpenModifier(event, isMac),
|
||||
containerRef: sourceControlRef
|
||||
})
|
||||
|
||||
@@ -3439,7 +3474,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}, [])
|
||||
|
||||
const openCommittedDiff = useCallback(
|
||||
(entry: GitBranchChangeEntry) => {
|
||||
(entry: GitBranchChangeEntry, event?: SourceControlRowOpenEvent) => {
|
||||
if (
|
||||
!activeWorktreeId ||
|
||||
!worktreePath ||
|
||||
@@ -3453,10 +3488,11 @@ function SourceControlInner(): React.JSX.Element {
|
||||
worktreePath,
|
||||
entry,
|
||||
branchSummary,
|
||||
detectLanguage(entry.path)
|
||||
detectLanguage(entry.path),
|
||||
{ targetGroupId: resolveSplitTargetGroupId(event) }
|
||||
)
|
||||
},
|
||||
[activeWorktreeId, branchSummary, openBranchDiff, worktreePath]
|
||||
[activeWorktreeId, branchSummary, openBranchDiff, resolveSplitTargetGroupId, worktreePath]
|
||||
)
|
||||
|
||||
const openHistoryCommitDiff = useCallback(
|
||||
@@ -4488,7 +4524,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
worktreePath={worktreePath}
|
||||
depth={node.depth}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
onOpen={() => openCommittedDiff(node.entry)}
|
||||
onOpen={(event) => openCommittedDiff(node.entry, event)}
|
||||
commentCount={diffCommentCountByPath.get(node.entry.path) ?? 0}
|
||||
showPathHint={false}
|
||||
/>
|
||||
@@ -4501,7 +4537,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
currentWorktreeId={currentWorktreeId}
|
||||
worktreePath={worktreePath}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
onOpen={() => openCommittedDiff(entry)}
|
||||
onOpen={(event) => openCommittedDiff(entry, event)}
|
||||
commentCount={diffCommentCountByPath.get(entry.path) ?? 0}
|
||||
/>
|
||||
)))}
|
||||
@@ -6230,7 +6266,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
||||
onSelect?: (e: React.MouseEvent, key: string, entry: GitStatusEntry) => void
|
||||
onContextMenu?: (key: string) => void
|
||||
onRevealInExplorer: (worktreeId: string, absolutePath: string) => void
|
||||
onOpen: (entry: GitStatusEntry) => void
|
||||
onOpen: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void
|
||||
onStage: (filePath: string) => Promise<void>
|
||||
onUnstage: (filePath: string) => Promise<void>
|
||||
onDiscard: (entry: GitStatusEntry) => void
|
||||
@@ -6300,7 +6336,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
||||
if (onSelect) {
|
||||
onSelect(e, entryKey, entry)
|
||||
} else {
|
||||
onOpen(entry)
|
||||
onOpen(entry, e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -6435,7 +6471,7 @@ function BranchEntryRow({
|
||||
worktreePath: string
|
||||
depth?: number
|
||||
onRevealInExplorer: (worktreeId: string, absolutePath: string) => void
|
||||
onOpen: () => void
|
||||
onOpen: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
commentCount: number
|
||||
showPathHint?: boolean
|
||||
}): React.JSX.Element {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isSourceControlSplitOpenModifier,
|
||||
type SourceControlRowOpenEvent
|
||||
} from './source-control-split-open'
|
||||
|
||||
function event(overrides: Partial<SourceControlRowOpenEvent> = {}): SourceControlRowOpenEvent {
|
||||
return {
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('isSourceControlSplitOpenModifier', () => {
|
||||
it('uses Cmd on macOS and Ctrl elsewhere as the platform primary modifier', () => {
|
||||
expect(isSourceControlSplitOpenModifier(event({ metaKey: true }), true)).toBe(true)
|
||||
expect(isSourceControlSplitOpenModifier(event({ ctrlKey: true }), true)).toBe(false)
|
||||
|
||||
expect(isSourceControlSplitOpenModifier(event({ ctrlKey: true }), false)).toBe(true)
|
||||
expect(isSourceControlSplitOpenModifier(event({ metaKey: true }), false)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats Shift and Alt/Option as split-open modifiers', () => {
|
||||
expect(isSourceControlSplitOpenModifier(event({ shiftKey: true }), true)).toBe(true)
|
||||
expect(isSourceControlSplitOpenModifier(event({ altKey: true }), false)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a plain click', () => {
|
||||
expect(isSourceControlSplitOpenModifier(event(), true)).toBe(false)
|
||||
expect(isSourceControlSplitOpenModifier(event(), false)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
export type SourceControlRowOpenEvent = {
|
||||
altKey: boolean
|
||||
ctrlKey: boolean
|
||||
metaKey: boolean
|
||||
shiftKey: boolean
|
||||
}
|
||||
|
||||
export function isSourceControlSplitOpenModifier(
|
||||
event: SourceControlRowOpenEvent,
|
||||
isMac: boolean
|
||||
): boolean {
|
||||
const platformPrimary = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
return platformPrimary || event.shiftKey || event.altKey
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import type { GitStatusEntry } from '../../../../shared/types'
|
||||
import type { SourceControlRowOpenEvent } from './source-control-split-open'
|
||||
|
||||
function isMacPlatform(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
|
||||
@@ -50,10 +51,12 @@ export function getSelectionRangeKeys(
|
||||
export function useSourceControlSelection({
|
||||
flatEntries,
|
||||
onOpenDiff,
|
||||
shouldOpenAsSplit,
|
||||
containerRef
|
||||
}: {
|
||||
flatEntries: FlatEntry[]
|
||||
onOpenDiff: (entry: GitStatusEntry) => void
|
||||
onOpenDiff: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void
|
||||
shouldOpenAsSplit?: (event: SourceControlRowOpenEvent) => boolean
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
}) {
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
|
||||
@@ -62,6 +65,7 @@ export function useSourceControlSelection({
|
||||
const anchorKeyRef = useRef<string | null>(anchorKey)
|
||||
const selectedKeysRef = useRef(selectedKeys)
|
||||
const onOpenDiffRef = useRef(onOpenDiff)
|
||||
const shouldOpenAsSplitRef = useRef(shouldOpenAsSplit)
|
||||
|
||||
useEffect(() => {
|
||||
flatEntriesRef.current = flatEntries
|
||||
@@ -79,6 +83,10 @@ export function useSourceControlSelection({
|
||||
onOpenDiffRef.current = onOpenDiff
|
||||
}, [onOpenDiff])
|
||||
|
||||
useEffect(() => {
|
||||
shouldOpenAsSplitRef.current = shouldOpenAsSplit
|
||||
}, [shouldOpenAsSplit])
|
||||
|
||||
// Clear stale selections if entries disappear
|
||||
useEffect(() => {
|
||||
const validKeys = new Set(flatEntries.map((e) => e.key))
|
||||
@@ -95,6 +103,13 @@ export function useSourceControlSelection({
|
||||
}, [flatEntries, selectedKeys, anchorKey])
|
||||
|
||||
const handleSelect = useCallback((e: React.MouseEvent, key: string, entry: GitStatusEntry) => {
|
||||
if (shouldOpenAsSplitRef.current?.(e)) {
|
||||
setSelectedKeys((prev) => (prev.size > 0 ? new Set() : prev))
|
||||
setAnchorKey(null)
|
||||
onOpenDiffRef.current(entry, e)
|
||||
return
|
||||
}
|
||||
|
||||
const isShift = e.shiftKey
|
||||
const isCmdOrCtrl = isMacPlatform() ? e.metaKey : e.ctrlKey
|
||||
|
||||
|
||||
@@ -321,6 +321,27 @@ describe('createEditorSlice openDiff', () => {
|
||||
])
|
||||
expect(store.getState().activeFileId).toBe('wt-1::diff::staged::file.ts')
|
||||
})
|
||||
|
||||
it('opens the visible diff tab in the requested split group', () => {
|
||||
const store = createEditorTabsStore()
|
||||
const sourceTab = store.getState().createUnifiedTab('wt-1', 'terminal', { id: 'terminal-1' })
|
||||
const targetGroupId = store.getState().createEmptySplitGroup('wt-1', sourceTab.groupId, 'right')
|
||||
if (!targetGroupId) {
|
||||
throw new Error('expected split group')
|
||||
}
|
||||
|
||||
store
|
||||
.getState()
|
||||
.openDiff('wt-1', '/repo/file.ts', 'file.ts', 'typescript', false, { targetGroupId })
|
||||
|
||||
const diffTab = store
|
||||
.getState()
|
||||
.unifiedTabsByWorktree['wt-1']?.find((tab) => tab.contentType === 'diff')
|
||||
|
||||
expect(diffTab?.groupId).toBe(targetGroupId)
|
||||
expect(diffTab?.entityId).toBe('wt-1::diff::unstaged::file.ts')
|
||||
expect(store.getState().activeGroupIdByWorktree['wt-1']).toBe(targetGroupId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEditorSlice floating editor activation', () => {
|
||||
|
||||
@@ -197,6 +197,10 @@ export type ClosedEditorTabSnapshot = Omit<OpenFile, 'id' | 'isDirty'>
|
||||
|
||||
const MAX_RECENT_CLOSED_EDITOR_TABS = 10
|
||||
|
||||
type EditorOpenTargetOptions = {
|
||||
targetGroupId?: string
|
||||
}
|
||||
|
||||
export type PendingEditorReveal = {
|
||||
filePath: string
|
||||
fileId?: string
|
||||
@@ -327,21 +331,24 @@ export type EditorSlice = {
|
||||
filePath: string,
|
||||
relativePath: string,
|
||||
language: string,
|
||||
staged: boolean
|
||||
staged: boolean,
|
||||
options?: EditorOpenTargetOptions
|
||||
) => void
|
||||
openBranchDiff: (
|
||||
worktreeId: string,
|
||||
worktreePath: string,
|
||||
entry: GitBranchChangeEntry,
|
||||
compare: BranchCompareLike,
|
||||
language: string
|
||||
language: string,
|
||||
options?: EditorOpenTargetOptions
|
||||
) => void
|
||||
openCommitDiff: (
|
||||
worktreeId: string,
|
||||
worktreePath: string,
|
||||
entry: GitBranchChangeEntry,
|
||||
compare: CommitCompareLike,
|
||||
language: string
|
||||
language: string,
|
||||
options?: EditorOpenTargetOptions
|
||||
) => void
|
||||
openAllDiffs: (
|
||||
worktreeId: string,
|
||||
@@ -353,7 +360,8 @@ export type EditorSlice = {
|
||||
worktreeId: string,
|
||||
worktreePath: string,
|
||||
entry: GitStatusEntry,
|
||||
language: string
|
||||
language: string,
|
||||
options?: EditorOpenTargetOptions
|
||||
) => void
|
||||
openConflictReviewFile: (
|
||||
reviewFileId: string,
|
||||
@@ -1938,7 +1946,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
openFiles: s.openFiles.map((f) => (f.id === fileId ? { ...f, isUntitled: undefined } : f))
|
||||
})),
|
||||
|
||||
openDiff: (worktreeId, filePath, relativePath, language, staged) => {
|
||||
openDiff: (worktreeId, filePath, relativePath, language, staged, options) => {
|
||||
set((s) => {
|
||||
const diffSource: DiffSource = staged ? 'staged' : 'unstaged'
|
||||
const id = `${worktreeId}::diff::${diffSource}::${relativePath}`
|
||||
@@ -1992,11 +2000,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
`${worktreeId}::diff::${staged ? 'staged' : 'unstaged'}::${relativePath}`,
|
||||
worktreeId,
|
||||
relativePath,
|
||||
'diff'
|
||||
'diff',
|
||||
undefined,
|
||||
options?.targetGroupId
|
||||
)
|
||||
},
|
||||
|
||||
openBranchDiff: (worktreeId, worktreePath, entry, compare, language) => {
|
||||
openBranchDiff: (worktreeId, worktreePath, entry, compare, language, options) => {
|
||||
const branchCompare = toBranchCompareSnapshot(compare)
|
||||
const id = `${worktreeId}::diff::branch::${compare.baseRef}::${branchCompare.compareVersion}::${entry.path}`
|
||||
set((s) => {
|
||||
@@ -2046,10 +2056,18 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
})
|
||||
void openWorkspaceEditorItem(get(), id, worktreeId, entry.path, 'diff')
|
||||
void openWorkspaceEditorItem(
|
||||
get(),
|
||||
id,
|
||||
worktreeId,
|
||||
entry.path,
|
||||
'diff',
|
||||
undefined,
|
||||
options?.targetGroupId
|
||||
)
|
||||
},
|
||||
|
||||
openCommitDiff: (worktreeId, worktreePath, entry, compare, language) => {
|
||||
openCommitDiff: (worktreeId, worktreePath, entry, compare, language, options) => {
|
||||
const commitCompare = toCommitCompareSnapshot(compare)
|
||||
const id = `${worktreeId}::diff::commit::${commitCompare.compareVersion}::${entry.path}`
|
||||
set((s) => {
|
||||
@@ -2099,7 +2117,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
|
||||
}
|
||||
})
|
||||
void openWorkspaceEditorItem(get(), id, worktreeId, entry.path, 'diff')
|
||||
void openWorkspaceEditorItem(
|
||||
get(),
|
||||
id,
|
||||
worktreeId,
|
||||
entry.path,
|
||||
'diff',
|
||||
undefined,
|
||||
options?.targetGroupId
|
||||
)
|
||||
},
|
||||
|
||||
openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter) => {
|
||||
@@ -2182,7 +2208,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
void openWorkspaceEditorItem(get(), id, worktreeId, label, 'diff')
|
||||
},
|
||||
|
||||
openConflictFile: (worktreeId, worktreePath, entry, language) => {
|
||||
openConflictFile: (worktreeId, worktreePath, entry, language, options) => {
|
||||
const absolutePath = joinPath(worktreePath, entry.path)
|
||||
set((s) => {
|
||||
const id = absolutePath
|
||||
@@ -2251,7 +2277,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
: { ...s.trackedConflictPathsByWorktree, [worktreeId]: nextTracked }
|
||||
}
|
||||
})
|
||||
void openWorkspaceEditorItem(get(), absolutePath, worktreeId, entry.path, 'editor')
|
||||
void openWorkspaceEditorItem(
|
||||
get(),
|
||||
absolutePath,
|
||||
worktreeId,
|
||||
entry.path,
|
||||
'editor',
|
||||
undefined,
|
||||
options?.targetGroupId
|
||||
)
|
||||
},
|
||||
|
||||
openConflictReviewFile: (reviewFileId, worktreeId, worktreePath, entry, language) => {
|
||||
|
||||
Reference in New Issue
Block a user