mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
Implement diff tab file tree from design doc
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# Diff Tab File Tree
|
||||
|
||||
## Problem
|
||||
|
||||
Source Control has the only file-tree navigation for combined diffs today.
|
||||
`SourceControl.tsx` builds and renders branch tree rows (`treeRootsByArea`/`visibleBranchTreeRows`, "Committed on Branch", "View all" at `openBranchAllDiffs`).
|
||||
|
||||
`CombinedDiffViewer` owns:
|
||||
- entry selection (`entries` from snapshot/live fallback)
|
||||
- section identity (`section.key`)
|
||||
- virtualized rendering + incremental runtime diff loading (`getRuntimeGitDiff`, `getRuntimeGitBranchDiff`, `getRuntimeGitCommitDiff`)
|
||||
|
||||
It currently has no in-tab tree, so navigation in combined tabs is linear.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a left file tree inside `CombinedDiffViewer` for combined uncommitted/branch/commit tabs.
|
||||
Tree clicks must navigate existing virtualized sections and use existing section loading only.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Source Control list/tree redesign
|
||||
- New git/diff backend APIs
|
||||
- Replacing combined diff virtualization/scheduler/cache
|
||||
- Persisting tree width/collapse state
|
||||
|
||||
## Design
|
||||
|
||||
1. Build tree from `entries` already chosen by `CombinedDiffViewer`.
|
||||
- Do not read alternative store slices for tree data.
|
||||
- Tree content must match section content exactly for that tab instance.
|
||||
|
||||
2. Reuse existing tree builders.
|
||||
- Uncommitted: `buildGitStatusSourceControlTree` + `compactSourceControlTree` + `flattenSourceControlTree`
|
||||
- Branch/commit: `buildSourceControlTree` + `compactSourceControlTree` + `flattenSourceControlTree`
|
||||
|
||||
3. Do not translate via tree node keys.
|
||||
- Tree file keys are `${area}::${entry.path}` (`entry.path`, not normalized node path).
|
||||
- Section keys are `${prefix}:${entry.path}` where prefix is:
|
||||
- uncommitted: `entry.area`
|
||||
- branch: `combined-branch`
|
||||
- commit: `combined-commit`
|
||||
- Build navigation target from `node.entry`, not by rewriting `node.key`.
|
||||
|
||||
4. Click behavior.
|
||||
- Resolve section index from `section.key -> index` map rebuilt on `sections` change.
|
||||
- If target section is collapsed, expand via existing `toggleSection(index)` (this already queues load on expand).
|
||||
- Scroll with `virtualizer.scrollToIndex(index, { align: 'start' })`.
|
||||
- Do not call loaders directly with new codepaths.
|
||||
|
||||
5. Layout.
|
||||
- Render tree + diff in a horizontal split under existing toolbar/header area.
|
||||
- Keep diff pane as the only vertical scroll container used by virtualizer.
|
||||
- Follow `docs/STYLEGUIDE.md` tokens/primitives only.
|
||||
|
||||
## Correctness Constraints
|
||||
|
||||
- Preserve empty/error/conflict-skipped states and existing toolbar actions.
|
||||
- Preserve runtime/SSH behavior by keeping current loader calls and `connectionId` wiring untouched.
|
||||
- Preserve section cache semantics (`entrySignature`, `generationRef`, module-level caches).
|
||||
|
||||
## Invalidation and Concurrency
|
||||
|
||||
- Recompute section lookup map whenever `sections` changes.
|
||||
- `toggleSection` mutates collapse state asynchronously; navigation should tolerate a short stale frame between expand and scroll.
|
||||
- Rely on existing generation guard (`generationRef`) for stale async loads when entries change mid-flight.
|
||||
- Keep tree collapse UI state local to the combined tab; do not couple to Source Control’s `collapsedTreeDirs` state.
|
||||
- Combined diff caches are renderer-process local; no cross-window synchronization.
|
||||
|
||||
## Important Existing Behavior to Keep (and Document)
|
||||
|
||||
- Uncommitted tabs use `uncommittedEntriesSnapshot` when present; otherwise `getCombinedUncommittedEntries(...)` fallback.
|
||||
- Commit tabs are snapshot-only (`commitEntriesSnapshot ?? []`).
|
||||
- Branch tabs currently use `branchEntriesSnapshot` only when snapshot length is `> 0`, otherwise they fall back to live branch entries.
|
||||
|
||||
That last branch rule is a real consistency edge case: a tab opened with an empty snapshot can later show newly refreshed live entries. If stability is required, this should be fixed in implementation (use `undefined` vs defined-empty as the fallback gate), not papered over in tree code.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Same path across staged/unstaged/untracked: keep area-specific section identity.
|
||||
- Renames: navigate by current `path`; preserve `oldPath` for loader/open actions.
|
||||
- Click during entry-set replacement: no-op safely if section index/key disappears.
|
||||
- Tabs created before snapshot fields existed: tree must mirror current fallback filtering (including unresolved conflict exclusion).
|
||||
|
||||
## Feasibility
|
||||
|
||||
- There is no "load all rendered sections in one call" API in current runtime diff APIs.
|
||||
- Tree navigation is a section jump aid over existing lazy per-section loading.
|
||||
- Reusing Source Control tree builders is feasible; selection/open state must remain local to combined diff viewer.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add `CombinedDiffFileTree` in `src/renderer/src/components/editor/`.
|
||||
2. Feed it the already-resolved `entries` from `CombinedDiffViewer`.
|
||||
3. Add mode-aware section-key mapping from row entry metadata.
|
||||
4. Wire row click to `toggleSection` (if needed) + `scrollToIndex`.
|
||||
5. Add focused tests for:
|
||||
- area-disambiguated same-path uncommitted entries
|
||||
- branch/commit key mapping
|
||||
- collapsed target navigation
|
||||
- branch empty-snapshot fallback behavior (documented current behavior or fixed behavior, whichever is chosen)
|
||||
6. Run renderer typecheck/lint/tests.
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createCombinedDiffSectionIndexMap,
|
||||
getCombinedDiffFileTreeNavigationIndex,
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
handleCombinedDiffFileTreeNavigation
|
||||
} from './CombinedDiffFileTree'
|
||||
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
|
||||
|
||||
describe('CombinedDiffFileTree navigation mapping', () => {
|
||||
it('disambiguates uncommitted entries with the same path by area', () => {
|
||||
const staged: GitStatusEntry = { path: 'src/App.tsx', status: 'modified', area: 'staged' }
|
||||
const unstaged: GitStatusEntry = { path: 'src/App.tsx', status: 'modified', area: 'unstaged' }
|
||||
const sectionIndexByKey = createCombinedDiffSectionIndexMap([
|
||||
{ key: 'unstaged:src/App.tsx' },
|
||||
{ key: 'staged:src/App.tsx' }
|
||||
])
|
||||
|
||||
expect(getCombinedDiffFileTreeSectionKey('uncommitted', unstaged)).toBe('unstaged:src/App.tsx')
|
||||
expect(
|
||||
getCombinedDiffFileTreeNavigationIndex({
|
||||
mode: 'uncommitted',
|
||||
entry: unstaged,
|
||||
sectionIndexByKey
|
||||
})
|
||||
).toBe(0)
|
||||
expect(
|
||||
getCombinedDiffFileTreeNavigationIndex({
|
||||
mode: 'uncommitted',
|
||||
entry: staged,
|
||||
sectionIndexByKey
|
||||
})
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('maps branch and commit entries to combined section prefixes', () => {
|
||||
const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'renamed' }
|
||||
|
||||
expect(getCombinedDiffFileTreeSectionKey('branch', entry)).toBe('combined-branch:src/view.ts')
|
||||
expect(getCombinedDiffFileTreeSectionKey('commit', entry)).toBe('combined-commit:src/view.ts')
|
||||
})
|
||||
|
||||
it('expands a collapsed target section and scrolls to its index', () => {
|
||||
const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' }
|
||||
const toggleSection = vi.fn()
|
||||
const scrollToIndex = vi.fn()
|
||||
const index = handleCombinedDiffFileTreeNavigation({
|
||||
mode: 'branch',
|
||||
entry,
|
||||
sections: [{ collapsed: false }, { collapsed: true }],
|
||||
sectionIndexByKey: createCombinedDiffSectionIndexMap([
|
||||
{ key: 'combined-branch:src/other.ts' },
|
||||
{ key: 'combined-branch:src/view.ts' }
|
||||
]),
|
||||
toggleSection,
|
||||
scrollToIndex
|
||||
})
|
||||
|
||||
expect(index).toBe(1)
|
||||
expect(toggleSection).toHaveBeenCalledWith(1)
|
||||
expect(scrollToIndex).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,372 @@
|
||||
import React from 'react'
|
||||
import { Check, ChevronDown, Filter, Folder, FolderOpen, Search } from 'lucide-react'
|
||||
import { basename, dirname } from '@/lib/path'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getFileTypeIcon } from '@/lib/file-type-icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
buildGitStatusSourceControlTree,
|
||||
buildSourceControlTree,
|
||||
compactSourceControlTree,
|
||||
flattenSourceControlTree,
|
||||
type SourceControlTreeNode
|
||||
} 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 {
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
getEntryExtension,
|
||||
getFilteredCombinedDiffFileTreeEntries,
|
||||
isGitStatusEntry,
|
||||
type CombinedDiffBranchTreeArea,
|
||||
type CombinedDiffFileTreeEntry,
|
||||
type CombinedDiffFileTreeMode
|
||||
} from './combined-diff-file-tree-model'
|
||||
|
||||
export {
|
||||
createCombinedDiffSectionIndexMap,
|
||||
getCombinedDiffFileTreeNavigationIndex,
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
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',
|
||||
staged: 'Staged Changes',
|
||||
untracked: 'Untracked Files'
|
||||
}
|
||||
|
||||
function buildUncommittedRows(
|
||||
entries: readonly CombinedDiffFileTreeEntry[],
|
||||
collapsedDirectoryKeys: ReadonlySet<string>
|
||||
): { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] }[] {
|
||||
return UNCOMMITTED_AREA_ORDER.map((area) => {
|
||||
const areaEntries = entries.filter(
|
||||
(entry): entry is GitStatusEntry => isGitStatusEntry(entry) && entry.area === area
|
||||
)
|
||||
if (areaEntries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries))
|
||||
return {
|
||||
area,
|
||||
label: UNCOMMITTED_AREA_LABELS[area],
|
||||
rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[]
|
||||
}
|
||||
}).filter(
|
||||
(group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } =>
|
||||
Boolean(group)
|
||||
)
|
||||
}
|
||||
|
||||
function buildBranchRows(
|
||||
mode: Extract<CombinedDiffFileTreeMode, 'branch' | 'commit'>,
|
||||
entries: readonly CombinedDiffFileTreeEntry[],
|
||||
collapsedDirectoryKeys: ReadonlySet<string>
|
||||
): CombinedDiffTreeNode[] {
|
||||
const branchEntries = entries.filter(
|
||||
(entry): entry is GitBranchChangeEntry => !isGitStatusEntry(entry)
|
||||
)
|
||||
const area: CombinedDiffBranchTreeArea = mode === 'branch' ? 'combined-branch' : 'combined-commit'
|
||||
const roots = compactSourceControlTree(buildSourceControlTree(area, branchEntries))
|
||||
return flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[]
|
||||
}
|
||||
|
||||
export function CombinedDiffFileTree({
|
||||
mode,
|
||||
entries,
|
||||
sectionIndexByKey,
|
||||
activeSectionKey,
|
||||
viewedSectionKeys,
|
||||
onNavigate
|
||||
}: {
|
||||
mode: CombinedDiffFileTreeMode
|
||||
entries: readonly CombinedDiffFileTreeEntry[]
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
activeSectionKey: string | null
|
||||
viewedSectionKeys: ReadonlySet<string>
|
||||
onNavigate: (entry: CombinedDiffFileTreeEntry) => void
|
||||
}): React.JSX.Element {
|
||||
const [collapsedDirectoryKeys, setCollapsedDirectoryKeys] = React.useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const [query, setQuery] = React.useState('')
|
||||
const [excludedExtensions, setExcludedExtensions] = React.useState<Set<string>>(() => new Set())
|
||||
const [includeViewed, setIncludeViewed] = React.useState(true)
|
||||
const toggleDirectory = React.useCallback((key: string) => {
|
||||
setCollapsedDirectoryKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const availableExtensions = React.useMemo(
|
||||
() => Array.from(new Set(entries.map(getEntryExtension))).sort(),
|
||||
[entries]
|
||||
)
|
||||
const filteredEntries = React.useMemo(
|
||||
() =>
|
||||
getFilteredCombinedDiffFileTreeEntries({
|
||||
entries,
|
||||
mode,
|
||||
query,
|
||||
excludedExtensions,
|
||||
includeViewed,
|
||||
viewedSectionKeys
|
||||
}),
|
||||
[entries, excludedExtensions, includeViewed, mode, query, viewedSectionKeys]
|
||||
)
|
||||
const toggleExtension = React.useCallback((extension: string) => {
|
||||
setExcludedExtensions((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(extension)) {
|
||||
next.delete(extension)
|
||||
} else {
|
||||
next.add(extension)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const resetFilters = React.useCallback(() => {
|
||||
setQuery('')
|
||||
setExcludedExtensions(new Set())
|
||||
setIncludeViewed(true)
|
||||
}, [])
|
||||
const activeFilterCount =
|
||||
excludedExtensions.size + (includeViewed ? 0 : 1) + (query.trim().length > 0 ? 1 : 0)
|
||||
|
||||
const uncommittedGroups = React.useMemo(
|
||||
() =>
|
||||
mode === 'uncommitted' ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) : [],
|
||||
[collapsedDirectoryKeys, filteredEntries, mode]
|
||||
)
|
||||
const branchRows = React.useMemo(
|
||||
() =>
|
||||
mode === 'branch' || mode === 'commit'
|
||||
? buildBranchRows(mode, filteredEntries, collapsedDirectoryKeys)
|
||||
: [],
|
||||
[collapsedDirectoryKeys, filteredEntries, mode]
|
||||
)
|
||||
|
||||
return (
|
||||
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-background">
|
||||
<div className="border-b border-border px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
Files
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-2 py-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Filter files..."
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Filter diff files"
|
||||
className={cn(activeFilterCount > 0 && 'border-foreground/30 text-foreground')}
|
||||
>
|
||||
<Filter className="size-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" side="bottom" sideOffset={6} className="w-56 p-0">
|
||||
<div className="border-b border-border px-3 py-2 text-xs font-semibold text-foreground">
|
||||
File extensions
|
||||
</div>
|
||||
<div className="max-h-60 overflow-auto py-1 scrollbar-sleek">
|
||||
{availableExtensions.map((extension) => {
|
||||
const checked = !excludedExtensions.has(extension)
|
||||
return (
|
||||
<button
|
||||
key={extension}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => toggleExtension(extension)}
|
||||
>
|
||||
<Check
|
||||
className={cn('size-3.5 shrink-0', checked ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{extension}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-border py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => setIncludeViewed((prev) => !prev)}
|
||||
>
|
||||
<Check
|
||||
className={cn('size-3.5 shrink-0', includeViewed ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">Viewed files</span>
|
||||
</button>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto py-1 scrollbar-sleek">
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
No files match the current filters.
|
||||
</div>
|
||||
) : mode === 'uncommitted' ? (
|
||||
uncommittedGroups.map((group) => (
|
||||
<div key={group.area} className="py-1">
|
||||
<div className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.rows.map((node) => (
|
||||
<CombinedDiffFileTreeRow
|
||||
key={node.key}
|
||||
node={node}
|
||||
mode={mode}
|
||||
activeSectionKey={activeSectionKey}
|
||||
sectionIndexByKey={sectionIndexByKey}
|
||||
isCollapsed={collapsedDirectoryKeys.has(node.key)}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
branchRows.map((node) => (
|
||||
<CombinedDiffFileTreeRow
|
||||
key={node.key}
|
||||
node={node}
|
||||
mode={mode}
|
||||
activeSectionKey={activeSectionKey}
|
||||
sectionIndexByKey={sectionIndexByKey}
|
||||
isCollapsed={collapsedDirectoryKeys.has(node.key)}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function CombinedDiffFileTreeRow({
|
||||
node,
|
||||
mode,
|
||||
activeSectionKey,
|
||||
sectionIndexByKey,
|
||||
isCollapsed,
|
||||
onToggleDirectory,
|
||||
onNavigate
|
||||
}: {
|
||||
node: CombinedDiffTreeNode
|
||||
mode: CombinedDiffFileTreeMode
|
||||
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`
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -50,7 +50,12 @@ import type {
|
||||
import { Check, Copy, MessageSquare, Send, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { DiffSectionItem } from './DiffSectionItem'
|
||||
import { getCombinedUncommittedEntries } from './combined-diff-entries'
|
||||
import {
|
||||
CombinedDiffFileTree,
|
||||
createCombinedDiffSectionIndexMap,
|
||||
handleCombinedDiffFileTreeNavigation
|
||||
} from './CombinedDiffFileTree'
|
||||
import { getCombinedBranchEntries, getCombinedUncommittedEntries } from './combined-diff-entries'
|
||||
import { getDiffSectionEstimatedHeight, isIntrinsicHeightImageDiff } from './diff-section-layout'
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load'
|
||||
@@ -225,17 +230,14 @@ export default function CombinedDiffViewer({
|
||||
[snapshotEntries, gitStatusEntries, file.combinedAreaFilter]
|
||||
)
|
||||
const branchEntries = React.useMemo<GitBranchChangeEntry[]>(() => {
|
||||
const snapshotEntries = file.branchEntriesSnapshot ?? []
|
||||
if (snapshotEntries.length > 0) {
|
||||
return snapshotEntries
|
||||
}
|
||||
return liveBranchEntries
|
||||
return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries)
|
||||
}, [file.branchEntriesSnapshot, liveBranchEntries])
|
||||
const commitEntries = React.useMemo<GitBranchChangeEntry[]>(
|
||||
() => file.commitEntriesSnapshot ?? [],
|
||||
[file.commitEntriesSnapshot]
|
||||
)
|
||||
const entries = isBranchMode ? branchEntries : isCommitMode ? commitEntries : uncommittedEntries
|
||||
const treeMode = isBranchMode ? 'branch' : isCommitMode ? 'commit' : 'uncommitted'
|
||||
const entrySignature = React.useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
@@ -573,6 +575,34 @@ export default function CombinedDiffViewer({
|
||||
loadSchedulerRef.current.request(index)
|
||||
}
|
||||
}, [])
|
||||
const sectionIndexByKey = React.useMemo(
|
||||
() => createCombinedDiffSectionIndexMap(sections),
|
||||
[sections]
|
||||
)
|
||||
const [activeTreeSectionKey, setActiveTreeSectionKey] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
setActiveTreeSectionKey(null)
|
||||
}, [entrySignature])
|
||||
const viewedSectionKeys = React.useMemo(
|
||||
() => new Set(sections.filter((section) => !section.loading).map((section) => section.key)),
|
||||
[sections]
|
||||
)
|
||||
const handleTreeNavigate = useCallback(
|
||||
(entry: GitStatusEntry | GitBranchChangeEntry) => {
|
||||
const navigatedIndex = handleCombinedDiffFileTreeNavigation({
|
||||
mode: treeMode,
|
||||
entry,
|
||||
sections: sectionsRef.current,
|
||||
sectionIndexByKey,
|
||||
toggleSection,
|
||||
scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' })
|
||||
})
|
||||
if (navigatedIndex !== null) {
|
||||
setActiveTreeSectionKey(sectionsRef.current[navigatedIndex]?.key ?? null)
|
||||
}
|
||||
},
|
||||
[sectionIndexByKey, toggleSection, treeMode, virtualizer]
|
||||
)
|
||||
|
||||
const setAllSectionsCollapsed = useCallback((collapsed: boolean) => {
|
||||
combinedDiffCollapsedPreference = collapsed
|
||||
@@ -1065,48 +1095,60 @@ export default function CombinedDiffViewer({
|
||||
</div>
|
||||
|
||||
{commitHeader}
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-auto scrollbar-editor">
|
||||
{skippedConflictNotice}
|
||||
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const section = sections[virtualItem.index]
|
||||
if (!section) {
|
||||
return null
|
||||
}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<CombinedDiffFileTree
|
||||
mode={treeMode}
|
||||
entries={entries}
|
||||
sectionIndexByKey={sectionIndexByKey}
|
||||
activeSectionKey={activeTreeSectionKey}
|
||||
viewedSectionKeys={viewedSectionKeys}
|
||||
onNavigate={handleTreeNavigate}
|
||||
/>
|
||||
<div ref={scrollContainerRef} className="min-w-0 flex-1 overflow-auto scrollbar-editor">
|
||||
{skippedConflictNotice}
|
||||
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const section = sections[virtualItem.index]
|
||||
if (!section) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
// Why: `top` preserves sticky file headers inside each row;
|
||||
// transform-based virtualization creates a containing block
|
||||
// that makes long-section headers feel jumpy while scrolling.
|
||||
style={{ top: `${virtualItem.start}px` }}
|
||||
>
|
||||
<DiffSectionItem
|
||||
section={section}
|
||||
index={virtualItem.index}
|
||||
isBranchMode={isBranchMode}
|
||||
sideBySide={sideBySide}
|
||||
isDark={isDark}
|
||||
settings={settings}
|
||||
sectionHeight={sectionHeights[virtualItem.index]}
|
||||
worktreeId={file.worktreeId}
|
||||
loadSection={loadSection}
|
||||
retrySection={retrySection}
|
||||
toggleSection={toggleSection}
|
||||
openSection={openSection}
|
||||
openSectionTitle={isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'}
|
||||
setSectionHeights={setSectionHeights}
|
||||
setSections={setSections}
|
||||
modifiedEditorsRef={modifiedEditorsRef}
|
||||
handleSectionSaveRef={handleSectionSaveRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
// Why: `top` preserves sticky file headers inside each row;
|
||||
// transform-based virtualization creates a containing block
|
||||
// that makes long-section headers feel jumpy while scrolling.
|
||||
style={{ top: `${virtualItem.start}px` }}
|
||||
>
|
||||
<DiffSectionItem
|
||||
section={section}
|
||||
index={virtualItem.index}
|
||||
isBranchMode={isBranchMode}
|
||||
sideBySide={sideBySide}
|
||||
isDark={isDark}
|
||||
settings={settings}
|
||||
sectionHeight={sectionHeights[virtualItem.index]}
|
||||
worktreeId={file.worktreeId}
|
||||
loadSection={loadSection}
|
||||
retrySection={retrySection}
|
||||
toggleSection={toggleSection}
|
||||
openSection={openSection}
|
||||
openSectionTitle={
|
||||
isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'
|
||||
}
|
||||
setSectionHeights={setSectionHeights}
|
||||
setSections={setSections}
|
||||
modifiedEditorsRef={modifiedEditorsRef}
|
||||
handleSectionSaveRef={handleSectionSaveRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getCombinedUncommittedEntries } from './combined-diff-entries'
|
||||
import type { GitStatusEntry } from '../../../../shared/types'
|
||||
import { getCombinedBranchEntries, getCombinedUncommittedEntries } from './combined-diff-entries'
|
||||
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
|
||||
|
||||
describe('getCombinedUncommittedEntries', () => {
|
||||
it('filters unresolved conflicts from live entries', () => {
|
||||
@@ -44,3 +44,17 @@ describe('getCombinedUncommittedEntries', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCombinedBranchEntries', () => {
|
||||
it('uses an explicitly empty snapshot instead of falling back to live entries', () => {
|
||||
const liveEntries: GitBranchChangeEntry[] = [{ path: 'src/live.ts', status: 'modified' }]
|
||||
|
||||
expect(getCombinedBranchEntries([], liveEntries)).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to live entries when no snapshot exists', () => {
|
||||
const liveEntries: GitBranchChangeEntry[] = [{ path: 'src/live.ts', status: 'modified' }]
|
||||
|
||||
expect(getCombinedBranchEntries(undefined, liveEntries)).toEqual(liveEntries)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { GitStatusEntry } from '../../../../shared/types'
|
||||
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
|
||||
|
||||
/**
|
||||
* Fallback filtering for combined-diff tabs that were opened before the
|
||||
@@ -20,3 +20,12 @@ export function getCombinedUncommittedEntries(
|
||||
return entry.area !== 'untracked'
|
||||
})
|
||||
}
|
||||
|
||||
export function getCombinedBranchEntries(
|
||||
snapshotEntries: readonly GitBranchChangeEntry[] | undefined,
|
||||
liveEntries: readonly GitBranchChangeEntry[]
|
||||
): GitBranchChangeEntry[] {
|
||||
// Why: an explicitly empty tab snapshot should stay empty instead of drifting
|
||||
// to later Source Control refreshes.
|
||||
return [...(snapshotEntries ?? liveEntries)]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { basename } from '@/lib/path'
|
||||
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
|
||||
|
||||
export type CombinedDiffFileTreeMode = 'uncommitted' | 'branch' | 'commit'
|
||||
export type CombinedDiffFileTreeEntry = GitStatusEntry | GitBranchChangeEntry
|
||||
export type CombinedDiffBranchTreeArea = 'combined-branch' | 'combined-commit'
|
||||
|
||||
export const NO_EXTENSION_KEY = '(no extension)'
|
||||
|
||||
export function getCombinedDiffFileTreeSectionKey(
|
||||
mode: CombinedDiffFileTreeMode,
|
||||
entry: CombinedDiffFileTreeEntry
|
||||
): string {
|
||||
if (mode === 'uncommitted' && 'area' in entry) {
|
||||
return `${entry.area}:${entry.path}`
|
||||
}
|
||||
return `${mode === 'branch' ? 'combined-branch' : 'combined-commit'}:${entry.path}`
|
||||
}
|
||||
|
||||
export function createCombinedDiffSectionIndexMap(
|
||||
sections: readonly { key: string }[]
|
||||
): Map<string, number> {
|
||||
return new Map(sections.map((section, index) => [section.key, index]))
|
||||
}
|
||||
|
||||
export function getCombinedDiffFileTreeNavigationIndex({
|
||||
mode,
|
||||
entry,
|
||||
sectionIndexByKey
|
||||
}: {
|
||||
mode: CombinedDiffFileTreeMode
|
||||
entry: CombinedDiffFileTreeEntry
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
}): number | null {
|
||||
return sectionIndexByKey.get(getCombinedDiffFileTreeSectionKey(mode, entry)) ?? null
|
||||
}
|
||||
|
||||
export function handleCombinedDiffFileTreeNavigation({
|
||||
mode,
|
||||
entry,
|
||||
sections,
|
||||
sectionIndexByKey,
|
||||
toggleSection,
|
||||
scrollToIndex
|
||||
}: {
|
||||
mode: CombinedDiffFileTreeMode
|
||||
entry: CombinedDiffFileTreeEntry
|
||||
sections: readonly { collapsed: boolean }[]
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
toggleSection: (index: number) => void
|
||||
scrollToIndex: (index: number) => void
|
||||
}): number | null {
|
||||
const index = getCombinedDiffFileTreeNavigationIndex({ mode, entry, sectionIndexByKey })
|
||||
if (index === null || !sections[index]) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (sections[index].collapsed) {
|
||||
toggleSection(index)
|
||||
}
|
||||
scrollToIndex(index)
|
||||
return index
|
||||
}
|
||||
|
||||
export function isGitStatusEntry(entry: CombinedDiffFileTreeEntry): entry is GitStatusEntry {
|
||||
return 'area' in entry
|
||||
}
|
||||
|
||||
export function getEntryExtension(entry: CombinedDiffFileTreeEntry): string {
|
||||
const name = basename(entry.path)
|
||||
const index = name.lastIndexOf('.')
|
||||
if (index <= 0 || index === name.length - 1) {
|
||||
return NO_EXTENSION_KEY
|
||||
}
|
||||
return name.slice(index).toLowerCase()
|
||||
}
|
||||
|
||||
function getEntrySearchText(entry: CombinedDiffFileTreeEntry): string {
|
||||
return [entry.path, entry.oldPath ?? '', entry.status, isGitStatusEntry(entry) ? entry.area : '']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function getFilteredCombinedDiffFileTreeEntries({
|
||||
entries,
|
||||
mode,
|
||||
query,
|
||||
excludedExtensions,
|
||||
includeViewed,
|
||||
viewedSectionKeys
|
||||
}: {
|
||||
entries: readonly CombinedDiffFileTreeEntry[]
|
||||
mode: CombinedDiffFileTreeMode
|
||||
query: string
|
||||
excludedExtensions: ReadonlySet<string>
|
||||
includeViewed: boolean
|
||||
viewedSectionKeys: ReadonlySet<string>
|
||||
}): CombinedDiffFileTreeEntry[] {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
return entries.filter((entry) => {
|
||||
if (excludedExtensions.has(getEntryExtension(entry))) {
|
||||
return false
|
||||
}
|
||||
if (!includeViewed && viewedSectionKeys.has(getCombinedDiffFileTreeSectionKey(mode, entry))) {
|
||||
return false
|
||||
}
|
||||
return normalizedQuery.length === 0 || getEntrySearchText(entry).includes(normalizedQuery)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user