diff --git a/docs/diff-tab-file-tree.md b/docs/diff-tab-file-tree.md new file mode 100644 index 00000000000..e46fdd91bd4 --- /dev/null +++ b/docs/diff-tab-file-tree.md @@ -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. diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts b/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts new file mode 100644 index 00000000000..e1eb6688802 --- /dev/null +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx new file mode 100644 index 00000000000..4fff4eaeb8f --- /dev/null +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -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 = { + unstaged: 'Changes', + staged: 'Staged Changes', + untracked: 'Untracked Files' +} + +function buildUncommittedRows( + entries: readonly CombinedDiffFileTreeEntry[], + collapsedDirectoryKeys: ReadonlySet +): { 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, + entries: readonly CombinedDiffFileTreeEntry[], + collapsedDirectoryKeys: ReadonlySet +): 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 + activeSectionKey: string | null + viewedSectionKeys: ReadonlySet + onNavigate: (entry: CombinedDiffFileTreeEntry) => void +}): React.JSX.Element { + const [collapsedDirectoryKeys, setCollapsedDirectoryKeys] = React.useState>( + () => new Set() + ) + const [query, setQuery] = React.useState('') + const [excludedExtensions, setExcludedExtensions] = React.useState>(() => 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 ( + + ) +} + +function CombinedDiffFileTreeRow({ + node, + mode, + activeSectionKey, + sectionIndexByKey, + isCollapsed, + onToggleDirectory, + onNavigate +}: { + node: CombinedDiffTreeNode + mode: CombinedDiffFileTreeMode + activeSectionKey: string | null + sectionIndexByKey: ReadonlyMap + isCollapsed: boolean + onToggleDirectory: (key: string) => void + onNavigate: (entry: CombinedDiffFileTreeEntry) => void +}): React.JSX.Element { + if (node.type === 'directory') { + return ( +
+ + + {node.fileCount} + +
+ ) + } + + const sectionKey = getCombinedDiffFileTreeSectionKey(mode, node.entry) + const FileIcon = getFileTypeIcon(node.entry.path) + const fileName = basename(node.entry.path) + const parentDir = dirname(node.entry.path) + const dirPath = parentDir === '.' ? '' : parentDir + const status = node.entry.status as GitFileStatus + const disabled = !sectionIndexByKey.has(sectionKey) + + return ( + + ) +} diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index cef5efde638..ae460e5fc76 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -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(() => { - const snapshotEntries = file.branchEntriesSnapshot ?? [] - if (snapshotEntries.length > 0) { - return snapshotEntries - } - return liveBranchEntries + return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries) }, [file.branchEntriesSnapshot, liveBranchEntries]) const commitEntries = React.useMemo( () => 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(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({ {commitHeader} -
- {skippedConflictNotice} -
- {virtualizer.getVirtualItems().map((virtualItem) => { - const section = sections[virtualItem.index] - if (!section) { - return null - } +
+ +
+ {skippedConflictNotice} +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const section = sections[virtualItem.index] + if (!section) { + return null + } - return ( -
- -
- ) - })} + return ( +
+ +
+ ) + })} +
diff --git a/src/renderer/src/components/editor/combined-diff-entries.test.ts b/src/renderer/src/components/editor/combined-diff-entries.test.ts index b917eb38906..d16fa039217 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.test.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff-entries.ts b/src/renderer/src/components/editor/combined-diff-entries.ts index 2b5c563f229..14760138207 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.ts @@ -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)] +} diff --git a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts new file mode 100644 index 00000000000..356f768c20c --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts @@ -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 { + return new Map(sections.map((section, index) => [section.key, index])) +} + +export function getCombinedDiffFileTreeNavigationIndex({ + mode, + entry, + sectionIndexByKey +}: { + mode: CombinedDiffFileTreeMode + entry: CombinedDiffFileTreeEntry + sectionIndexByKey: ReadonlyMap +}): 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 + 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 + includeViewed: boolean + viewedSectionKeys: ReadonlySet +}): 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) + }) +}