From a7c76baf102ae8b872f5e84a53e1ac3b6a4e1ffc Mon Sep 17 00:00:00 2001 From: "Sam A. Horvath-Hunt" Date: Fri, 28 Aug 2026 10:17:01 +0100 Subject: [PATCH 1/2] Embrace iterators Today I discovered that iterators are lazy, produce no intermediate allocations, and their creation is O(1) with only minor iteration overhead. They are similar to stream fusion in Haskell. This makes them a generally more performant, more readable - albeit more verbose - alternative to where I've abused flatMap as a filterMap. This commit is me playing with them. It's a minor improvement we may as well keep. --- apps/lite/ui/src/checking.ts | 10 +- apps/lite/ui/src/hunk.ts | 32 ++-- apps/lite/ui/src/iterator.ts | 13 ++ apps/lite/ui/src/native-menu.ts | 3 - apps/lite/ui/src/project-events.ts | 28 ++-- apps/lite/ui/src/projects/project.ts | 4 +- apps/lite/ui/src/reconcile.ts | 152 +++++++++++------- apps/lite/ui/src/router.ts | 13 +- .../project/$id/workspace/AnnotationCard.tsx | 2 +- .../$id/workspace/ApplyBranchPicker.tsx | 43 ++--- .../project/$id/workspace/BranchPicker.tsx | 12 +- .../project/$id/workspace/CommandPalette.tsx | 59 ++++--- .../project/$id/workspace/CommitForm.tsx | 14 +- .../project/$id/workspace/ConflictBar.tsx | 17 +- .../$id/workspace/DependencyIndicator.tsx | 5 +- .../routes/project/$id/workspace/Details.tsx | 104 +++++++----- .../project/$id/workspace/FilesTree.tsx | 16 +- .../routes/project/$id/workspace/Sidebar.tsx | 13 +- .../WorkspaceLists/WorkspaceLists.tsx | 66 +++++--- .../commitTargetComboboxItems.ts | 21 +-- .../WorkspaceLists/useStackMenuItems.ts | 14 +- .../$id/workspace/applied-address-space.ts | 39 +++-- .../project/$id/workspace/diff-line-target.ts | 7 +- .../project/$id/workspace/diff-minimap.ts | 20 ++- .../routes/project/$id/workspace/file-row.ts | 36 +++-- .../$id/workspace/useCheckedActions.ts | 16 +- .../project/$id/workspace/useUpstreamList.ts | 47 +++--- 27 files changed, 480 insertions(+), 326 deletions(-) create mode 100644 apps/lite/ui/src/iterator.ts diff --git a/apps/lite/ui/src/checking.ts b/apps/lite/ui/src/checking.ts index b907cdba5b1..117b1b84bab 100644 --- a/apps/lite/ui/src/checking.ts +++ b/apps/lite/ui/src/checking.ts @@ -27,10 +27,12 @@ export const addressSpaceRange = const end = Math.max(anchorIndex, targetIndex); return new Set( - navidx.items.slice(start, end + 1).flatMap((item) => { - const id = filterMap(item); - return id === null ? [] : [id]; - }), + navidx.items + .values() + .drop(start) + .take(end - start + 1) + .map(filterMap) + .filter((x) => x != null), ); }; diff --git a/apps/lite/ui/src/hunk.ts b/apps/lite/ui/src/hunk.ts index f50072e8202..839a2590b3c 100644 --- a/apps/lite/ui/src/hunk.ts +++ b/apps/lite/ui/src/hunk.ts @@ -448,20 +448,24 @@ export const hunkSelectionForLineNavigation = ({ if (rangeStart === -1 || rangeEnd === -1) return null; const active = offset === 1 ? Math.max(rangeStart, rangeEnd) : Math.min(rangeStart, rangeEnd); - const positioned = selections.flatMap((selection) => { - const selectionRange = rangeFromLineGroups(selection.lineGroups); - if (!selectionRange) return []; - - const start = indexOfPoint(lineIndex, selectionRange.start, selectionRange.side); - const end = indexOfPoint( - lineIndex, - selectionRange.end, - selectionRange.endSide ?? selectionRange.side, - ); - if (start === -1 || end === -1) return []; - - return [{ selection, start: Math.min(start, end), end: Math.max(start, end) }]; - }); + const positioned = selections + .values() + .map((selection) => { + const selectionRange = rangeFromLineGroups(selection.lineGroups); + if (!selectionRange) return null; + + const start = indexOfPoint(lineIndex, selectionRange.start, selectionRange.side); + const end = indexOfPoint( + lineIndex, + selectionRange.end, + selectionRange.endSide ?? selectionRange.side, + ); + if (start === -1 || end === -1) return null; + + return { selection, start: Math.min(start, end), end: Math.max(start, end) }; + }) + .filter((x) => x != null) + .toArray(); const containingIndex = positioned.findIndex( ({ start, end }) => active >= start && active <= end, diff --git a/apps/lite/ui/src/iterator.ts b/apps/lite/ui/src/iterator.ts new file mode 100644 index 00000000000..be8dae76ccb --- /dev/null +++ b/apps/lite/ui/src/iterator.ts @@ -0,0 +1,13 @@ +/** Ponyfill of `Iterator.concat`, unavailable until Node.js v26. */ +export function iteratorConcat(...iterables: Array>): IteratorObject { + return (function* (): Generator { + for (const iterable of iterables) yield* iterable; + })(); +} + +/** Like `Array.prototype.values`, but iterates in reverse order. */ +export function* reverseValues(array: Array): Generator { + for (let index = array.length - 1; index >= 0; index--) + // oxlint-disable-next-line typescript/no-non-null-assertion + yield array[index]!; +} diff --git a/apps/lite/ui/src/native-menu.ts b/apps/lite/ui/src/native-menu.ts index 78c4ba81344..68fa1e06776 100644 --- a/apps/lite/ui/src/native-menu.ts +++ b/apps/lite/ui/src/native-menu.ts @@ -15,18 +15,15 @@ export type NativeMenuItemData = { export type NativeMenuItem = { _tag: "Separator" } | ({ _tag: "Item" } & NativeMenuItemData); -/** @public */ export const nativeMenuSeparator: NativeMenuItem = { _tag: "Separator", }; -/** @public */ export const nativeMenuItem = (item: NativeMenuItemData): NativeMenuItem => ({ _tag: "Item", ...item, }); -/** @public */ export const nativeMenuItemsFromGroups = ( groups: Array>, ): Array => diff --git a/apps/lite/ui/src/project-events.ts b/apps/lite/ui/src/project-events.ts index 52f0a1f212c..93da1913c2e 100644 --- a/apps/lite/ui/src/project-events.ts +++ b/apps/lite/ui/src/project-events.ts @@ -88,23 +88,25 @@ const refreshIntegratedReviews = async (client: QueryClient, projectId: string): queryFn: () => window.lite.headInfo(projectId), staleTime: 0, }); + const reviewIds = new Set( - headInfo.stacks.flatMap((stack) => - stack.segments.flatMap((segment) => { - // Integrated segments only: an open association needs no - // refresh here, and the landed view fetches on demand. - const reviewId = segment.pushStatus === "integrated" ? recordedPullRequest(segment) : null; - return reviewId !== null ? [reviewId] : []; - }), + headInfo.stacks.values().flatMap((stack) => + stack.segments + .values() + // Integrated segments only: an open association needs no refresh here, and the landed view + // fetches on demand. + .filter((segment) => segment.pushStatus === "integrated") + .map(recordedPullRequest) + .filter((x) => x != null), ), ); + await Promise.allSettled( - [...reviewIds].flatMap((reviewId) => { - const options = getReviewQueryOptions({ projectId, reviewId }); - return client.getQueryData(options.queryKey)?.mergedAt != null - ? [] - : [client.fetchQuery({ ...options, staleTime: Number.POSITIVE_INFINITY })]; - }), + reviewIds + .values() + .map((reviewId) => getReviewQueryOptions({ projectId, reviewId })) + .filter((options) => client.getQueryData(options.queryKey)?.mergedAt == null) + .map((options) => client.fetchQuery({ ...options, staleTime: Number.POSITIVE_INFINITY })), ); }; diff --git a/apps/lite/ui/src/projects/project.ts b/apps/lite/ui/src/projects/project.ts index 721749c1e0a..cda3aed8a67 100644 --- a/apps/lite/ui/src/projects/project.ts +++ b/apps/lite/ui/src/projects/project.ts @@ -55,7 +55,7 @@ import { /** The workspace page's two lists; the one named here is active and drives the details pane. */ export type ActiveList = "applied" | "uncommitted"; -type CheckableAddress = Extract; +export type CheckableAddress = Extract; export type BranchTab = "diff" | "pr"; @@ -63,7 +63,7 @@ export type BranchTab = "diff" | "pr"; * A conflict checked for a batch resolution. Ids survive the rewrites that * compact hunk positions, so checks carry across; the commit id is remapped. */ -type CheckedConflict = { commitId: string; path: string; id: string }; +export type CheckedConflict = { commitId: string; path: string; id: string }; const conflictCheckKey = ({ commitId, path, id }: CheckedConflict): string => `${commitId}\u0000${path}\u0000${id}`; diff --git a/apps/lite/ui/src/reconcile.ts b/apps/lite/ui/src/reconcile.ts index d7cba0c340e..c299e6edf79 100644 --- a/apps/lite/ui/src/reconcile.ts +++ b/apps/lite/ui/src/reconcile.ts @@ -90,16 +90,20 @@ export const useStateReconciler = (projectId: string): void => { parent: FileParent; path: string; }; - const checkedFiles = checkedAddresses.flatMap((address) => { - switch (address._tag) { - case "File": - return [{ address, parent: address.parent, path: address.path }]; - case "Hunk": - return [{ address, parent: address.parent.parent, path: address.parent.path }]; - default: - return []; - } - }); + const checkedFiles = checkedAddresses + .values() + .map((address): FileScopedCheckedAddress | null => { + switch (address._tag) { + case "File": + return { address, parent: address.parent, path: address.path }; + case "Hunk": + return { address, parent: address.parent.parent, path: address.parent.path }; + default: + return null; + } + }) + .filter((x) => x != null) + .toArray(); const checkedUncommittedFiles = checkedFiles.filter( (file) => file.parent._tag === "UncommittedChanges", @@ -111,9 +115,11 @@ export const useStateReconciler = (projectId: string): void => { const { mutate: pruneReviewedFiles } = usePruneReviewedFiles(); const reconcileCheckedUncommittedFiles = useEffectEvent( (worktreeChangesByPath: Map) => { - const invalidated = checkedUncommittedFiles.flatMap((file) => - worktreeChangesByPath.has(file.path) ? [] : file.address, - ); + const invalidated = checkedUncommittedFiles + .values() + .map((file) => (worktreeChangesByPath.has(file.path) ? null : file.address)) + .filter((x) => x != null) + .toArray(); if (invalidated.length > 0) { dispatch( @@ -127,20 +133,26 @@ export const useStateReconciler = (projectId: string): void => { }, ); - const checkedCommitFiles = checkedFiles.flatMap((file) => - file.parent._tag === "Commit" ? { ...file, parent: file.parent } : [], - ); + const checkedCommitFiles = checkedFiles + .values() + .map((file) => (file.parent._tag === "Commit" ? { ...file, parent: file.parent } : null)) + .filter((x) => x != null) + .toArray(); const reconcileCheckedCommitFiles = useEffectEvent( ( headInfoIndex: HeadInfoIndex, checkedCommitFilesByCommitId: Map>, ) => { - const invalidated = checkedCommitFiles.flatMap((file) => - !headInfoIndex.commitContextByCommitId(file.parent.commitId) || - checkedCommitFilesByCommitId.get(file.parent.commitId)?.has(file.path) === false - ? file.address - : [], - ); + const invalidated = checkedCommitFiles + .values() + .map((file) => + !headInfoIndex.commitContextByCommitId(file.parent.commitId) || + checkedCommitFilesByCommitId.get(file.parent.commitId)?.has(file.path) === false + ? file.address + : null, + ) + .filter((x) => x != null) + .toArray(); if (invalidated.length > 0) { dispatch( @@ -154,18 +166,24 @@ export const useStateReconciler = (projectId: string): void => { }, ); - const checkedBranchFiles = checkedFiles.flatMap((file) => - file.parent._tag === "Branch" ? { ...file, parent: file.parent } : [], - ); + const checkedBranchFiles = checkedFiles + .values() + .map((file) => (file.parent._tag === "Branch" ? { ...file, parent: file.parent } : null)) + .filter((x) => x != null) + .toArray(); const reconcileCheckedBranchFiles = useEffectEvent( (headInfoIndex: HeadInfoIndex, checkedBranchFilesByBranchName: Map>) => { - const invalidated = checkedBranchFiles.flatMap((file) => - !headInfoIndex.isApplied(file.parent.branchRef) || - checkedBranchFilesByBranchName.get(decodeBytes(file.parent.branchRef))?.has(file.path) === - false - ? file.address - : [], - ); + const invalidated = checkedBranchFiles + .values() + .map((file) => + !headInfoIndex.isApplied(file.parent.branchRef) || + checkedBranchFilesByBranchName.get(decodeBytes(file.parent.branchRef))?.has(file.path) === + false + ? file.address + : null, + ) + .filter((x) => x != null) + .toArray(); if (invalidated.length > 0) { dispatch( @@ -238,16 +256,17 @@ export const useStateReconciler = (projectId: string): void => { ), combine: (results): Map> => new Map( - results.flatMap((result) => - result.data - ? [ - [ + results + .values() + .map((result) => + result.data + ? ([ result.data.commit.id, new Map(result.data.changes.map((change) => [change.path, change])), - ] as const, - ] - : [], - ), + ] as const) + : null, + ) + .filter((x) => x != null), ), }); useLayoutEffect(() => { @@ -265,12 +284,15 @@ export const useStateReconciler = (projectId: string): void => { ), combine: (results) => new Map( - results.flatMap((result, idx) => { - const key = checkedBranchFileBranchNames[idx]; - return key !== undefined && result.data - ? [[key, new Set(result.data.changes.map((change) => change.path))]] - : []; - }), + results + .values() + .map((result, idx) => { + const key = checkedBranchFileBranchNames[idx]; + return key !== undefined && result.data + ? ([key, new Set(result.data.changes.map((change) => change.path))] as const) + : null; + }) + .filter((x) => x != null), ), }); useLayoutEffect(() => { @@ -284,9 +306,10 @@ export const useStateReconciler = (projectId: string): void => { addressIdentityKey(fileAddress(hunk.parent)), ) .values() - .flatMap((hunks) => { + .map((hunks) => { const anyHunk = hunks[0]; - if (!anyHunk) return []; + if (!anyHunk) return null; + const { parent, path } = anyHunk.parent; const change = @@ -295,8 +318,9 @@ export const useStateReconciler = (projectId: string): void => { : parent._tag === "Commit" ? checkedCommitFilesByCommitId.get(parent.commitId)?.get(path) : undefined; - return change ? [{ change, hunks }] : []; + return change ? { change, hunks } : null; }) + .filter((x) => x != null) .toArray(); const validCheckedHunkKeys = useQueries({ queries: checkedHunkFiles.map(({ change }) => @@ -304,18 +328,24 @@ export const useStateReconciler = (projectId: string): void => { ), combine: (results): Set => new Set( - results.flatMap(({ data: patch }, index) => { - const file = checkedHunkFiles[index]; - if (!file || patch?.type !== "Patch") return []; - - return file.hunks.flatMap((hunk) => - hunk.isResultOfBinaryToTextConversion === - patch.subject.isResultOfBinaryToTextConversion && - patch.subject.hunks.some((current) => hunkContainsHunk(current, hunk.hunkHeader)) - ? addressIdentityKey(hunk) - : [], - ); - }), + results + .values() + .map(({ data: patch }, index) => { + const file = checkedHunkFiles[index]; + return file && patch?.type === "Patch" ? { file, patch } : null; + }) + .filter((x) => x != null) + .flatMap(({ file, patch }) => + file.hunks + .values() + .filter( + (hunk) => + hunk.isResultOfBinaryToTextConversion === + patch.subject.isResultOfBinaryToTextConversion && + patch.subject.hunks.some((current) => hunkContainsHunk(current, hunk.hunkHeader)), + ) + .map(addressIdentityKey), + ), ), }); const reconcileCheckedHunks = useEffectEvent((validHunkKeys: Set) => { diff --git a/apps/lite/ui/src/router.ts b/apps/lite/ui/src/router.ts index b4a89b4a1b6..c2ee7ba840f 100644 --- a/apps/lite/ui/src/router.ts +++ b/apps/lite/ui/src/router.ts @@ -6,11 +6,14 @@ import type { RouteTree } from "#ui/routes.tsx"; JSON search serialization would only add quoting noise. Slashes and colons are legal in query values and carry most of our params' legibility. */ const stringifySearch = (search: Record): string => { - const parts = Object.entries(search).flatMap(([key, value]) => - typeof value === "string" && value !== "" - ? [`${key}=${encodeURIComponent(value).replaceAll("%2F", "/").replaceAll("%3A", ":")}`] - : [], - ); + const parts = Object.entries(search) + .values() + .filter((pair): pair is [string, string] => typeof pair[1] === "string" && pair[1] !== "") + .map( + ([key, value]) => + `${key}=${encodeURIComponent(value).replaceAll("%2F", "/").replaceAll("%3A", ":")}`, + ) + .toArray(); return parts.length === 0 ? "" : `?${parts.join("&")}`; }; diff --git a/apps/lite/ui/src/routes/project/$id/workspace/AnnotationCard.tsx b/apps/lite/ui/src/routes/project/$id/workspace/AnnotationCard.tsx index ecb4d583c55..810f8915851 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/AnnotationCard.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/AnnotationCard.tsx @@ -77,7 +77,7 @@ export const AnnotationCard: FC = (p) => { .flatMap((form) => new FormData(form) .entries() - .flatMap(([id, body]) => (typeof body === "string" ? [[id, body]] : [])), + .filter((pair): pair is [string, string] => typeof pair[1] === "string"), ), ); diff --git a/apps/lite/ui/src/routes/project/$id/workspace/ApplyBranchPicker.tsx b/apps/lite/ui/src/routes/project/$id/workspace/ApplyBranchPicker.tsx index 9e40190cfea..f24cf703a2a 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/ApplyBranchPicker.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/ApplyBranchPicker.tsx @@ -22,34 +22,37 @@ type Props = { const listedBranchToApplyBranchPickerOptions = ( branch: ListedBranch, -): Array => { +): IteratorObject => { if (branch.hasLocal) { - return [ + return Iterator.from([ { branchRef: branch.refName.full, label: branch.displayName, type: "Local", updatedAt: branch.updatedAtMs, }, - ]; + ]); } - return branch.remoteRefs.flatMap(({ full }) => { - const { remote } = branchDetailsParams(full); + return branch.remoteRefs + .values() + .map(({ full }) => { + const { remote } = branchDetailsParams(full); - return remote !== null - ? { - branchRef: full, - label: branch.displayName, - type: remote, - updatedAt: branch.updatedAtMs, - } - : []; - }); + return remote == null + ? null + : { + branchRef: full, + label: branch.displayName, + type: remote, + updatedAt: branch.updatedAtMs, + }; + }) + .filter((x) => x != null); }; const groupApplyBranchPickerOptions = ( - items: Array, + items: Iterable, ): Array> => Array.from( Map.groupBy(items, (item) => item.type), @@ -71,11 +74,11 @@ const listedStacksToApplyBranchPickerGroups = ( stacks: Array, ): Array> => groupApplyBranchPickerOptions( - stacks.flatMap(({ status, branches }) => - status === "unapplied" || status === "standalone" - ? branches.flatMap(listedBranchToApplyBranchPickerOptions) - : [], - ), + stacks + .values() + .filter(({ status }) => status === "unapplied" || status === "standalone") + .flatMap(({ branches }) => branches) + .flatMap(listedBranchToApplyBranchPickerOptions), ); export const ApplyBranchPicker: FC = ({ open, onOpenChange, projectId }) => { diff --git a/apps/lite/ui/src/routes/project/$id/workspace/BranchPicker.tsx b/apps/lite/ui/src/routes/project/$id/workspace/BranchPicker.tsx index 52dba42397a..61db274613d 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/BranchPicker.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/BranchPicker.tsx @@ -33,11 +33,11 @@ const segmentToBranchPickerOption = ({ }; }; -const stackToBranchPickerOptions = (stack: Stack): Array => - stack.segments.flatMap((segment): Array => { - const option = segmentToBranchPickerOption({ segment }); - return option ? [option] : []; - }); +const stackToBranchPickerOptions = (stack: Stack): IteratorObject => + stack.segments + .values() + .map((segment) => segmentToBranchPickerOption({ segment })) + .filter((x) => x != null); export const BranchPicker: FC = ({ projectId, open, onOpenChange, onSelectBranch }) => { const { data: headInfo } = useQuery(headInfoQueryOptions(projectId)); @@ -58,7 +58,7 @@ export const BranchPicker: FC = ({ projectId, open, onOpenChange, onSelec items={[ { value: "Branches", - items: headInfo?.stacks.flatMap(stackToBranchPickerOptions) ?? [], + items: headInfo?.stacks.values().flatMap(stackToBranchPickerOptions).toArray() ?? [], }, ]} open={open} diff --git a/apps/lite/ui/src/routes/project/$id/workspace/CommandPalette.tsx b/apps/lite/ui/src/routes/project/$id/workspace/CommandPalette.tsx index e8149dd54ba..43424184bfd 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/CommandPalette.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/CommandPalette.tsx @@ -1,6 +1,7 @@ import { Kbd } from "#ui/components/Kbd.tsx"; import { PickerDialog, type PickerDialogGroup } from "#ui/components/PickerDialog.tsx"; import type { CommandGroup } from "#ui/hotkeys.ts"; +import { iteratorConcat } from "#ui/iterator.ts"; import { getHotkeyManager, getSequenceManager, @@ -27,7 +28,7 @@ type Props = { }; const groupCommandPaletteItems = ( - items: Array, + items: Iterable, ): Array> => { const grouped = Map.groupBy(items, (item) => item.group); @@ -54,32 +55,38 @@ const isEnabled = ( export const CommandPalette: FC = ({ open, onOpenChange }) => { const [initialActiveElement] = useState(() => document.activeElement); - const { hotkeys, sequences } = useHotkeyRegistrations(); - const hotkeyItems: Array = [ - ...hotkeys.flatMap((hotkey): CommandPaletteItem | [] => - isEnabled(hotkey.options, initialActiveElement) - ? { - group: hotkey.options.meta.group, - id: hotkey.id, - name: hotkey.options.meta.name, - hotkey: hotkey.hotkey, - type: "hotkey", - } - : [], - ), - ...sequences.flatMap((sequence): CommandPaletteItem | [] => - isEnabled(sequence.options, initialActiveElement) - ? { - group: sequence.options.meta.group, - id: sequence.id, - name: sequence.options.meta.name, - hotkey: sequence.sequence, - type: "sequence", - } - : [], - ), - ]; + + const hotkeyItems: IteratorObject = iteratorConcat( + hotkeys + .values() + .map((hotkey): CommandPaletteItem | null => + isEnabled(hotkey.options, initialActiveElement) + ? { + group: hotkey.options.meta.group, + id: hotkey.id, + name: hotkey.options.meta.name, + hotkey: hotkey.hotkey, + type: "hotkey", + } + : null, + ) + .filter((x) => x != null), + sequences + .values() + .map((sequence): CommandPaletteItem | null => + isEnabled(sequence.options, initialActiveElement) + ? { + group: sequence.options.meta.group, + id: sequence.id, + name: sequence.options.meta.name, + hotkey: sequence.sequence, + type: "sequence", + } + : null, + ) + .filter((x) => x != null), + ); const items = groupCommandPaletteItems(hotkeyItems); const runHotkey = (item: CommandPaletteItem) => { diff --git a/apps/lite/ui/src/routes/project/$id/workspace/CommitForm.tsx b/apps/lite/ui/src/routes/project/$id/workspace/CommitForm.tsx index 5e52c668fde..f0a1aeffcf8 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/CommitForm.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/CommitForm.tsx @@ -235,11 +235,15 @@ export const CommitForm: FC<{ projectId, message: commitTextareaRef.current?.value ?? draftMessage ?? "", relativeTo, - changes: worktreeChanges.changes.flatMap((change) => - checkedUncommittedFilePaths.size === 0 || checkedUncommittedFilePaths.has(change.path) - ? [createDiffSpec(change, [])] - : [], - ), + changes: worktreeChanges.changes + .values() + .filter( + (change) => + checkedUncommittedFilePaths.size === 0 || + checkedUncommittedFilePaths.has(change.path), + ) + .map((change) => createDiffSpec(change, [])) + .toArray(), changesSource: { type: "head" }, side: Match.value(relativeTo).pipe( Match.withReturnType(), diff --git a/apps/lite/ui/src/routes/project/$id/workspace/ConflictBar.tsx b/apps/lite/ui/src/routes/project/$id/workspace/ConflictBar.tsx index 2e2ee7cde78..bde6247db0e 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/ConflictBar.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/ConflictBar.tsx @@ -16,6 +16,7 @@ import type { ManualConflict, ResolutionSpec, } from "@gitbutler/but-sdk"; +import type { CheckedConflict } from "#ui/projects/project.ts"; type Props = { projectId: string; @@ -66,10 +67,18 @@ export const ConflictBar: FC = (p) => { // Ids resolve to current positions; stale checks match nothing. const liveByPath = new Map(p.conflicts.map((file) => [file.path, file])); - const checkedLive = checked.flatMap((check) => { - const index = liveByPath.get(check.path)?.hunks.findIndex((hunk) => hunk.id === check.id); - return index === undefined || index === -1 ? [] : [{ path: check.path, hunk: index + 1 }]; - }); + const checkedLive = checked + .values() + .map( + (check) => + [ + check, + liveByPath.get(check.path)?.hunks.findIndex((hunk) => hunk.id === check.id), + ] as const, + ) + .filter((pair): pair is [CheckedConflict, number] => pair[1] != null && pair[1] !== -1) + .map(([check, index]) => ({ path: check.path, hunk: index + 1 })) + .toArray(); const apply = (resolution: HunkResolution) => { if (p.busy || checkedLive.length === 0) return; diff --git a/apps/lite/ui/src/routes/project/$id/workspace/DependencyIndicator.tsx b/apps/lite/ui/src/routes/project/$id/workspace/DependencyIndicator.tsx index b6495507bc6..46dd752281a 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/DependencyIndicator.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/DependencyIndicator.tsx @@ -17,7 +17,10 @@ export const DependencyIndicator: FC< const ownedCommitIds = useRef | null>(null); const branchNames = new Set( - commitIds.flatMap((commitId) => branchNameByCommitId(commitId) ?? []), + commitIds + .values() + .map((commitId) => branchNameByCommitId(commitId)) + .filter((x) => x != null), ); const tooltip = branchNames.size > 0 diff --git a/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx b/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx index 41ec651fa67..a0141dd5e54 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx @@ -57,7 +57,7 @@ import { } from "#ui/addresses.ts"; import type { DiffLineSelection } from "#ui/cursors.ts"; import { checkedRange, addressSpaceRange } from "#ui/checking.ts"; -import type { BranchTab } from "#ui/projects/project.ts"; +import type { BranchTab, CheckableAddress } from "#ui/projects/project.ts"; import { projectSlice } from "#ui/projects/state.ts"; import { interfaceSlice } from "#ui/interface/state.ts"; import { Badge } from "#ui/components/Badge.tsx"; @@ -452,22 +452,25 @@ const DiffContents: FC<{ const hunkCheckRangeEnd = useRef(null); const collapsedItems: Set = new Set( - items.flatMap((item) => { - const manuallyCollapsed = manualCollapseByItem.get(item.id); - if (manuallyCollapsed !== undefined) return manuallyCollapsed ? item.id : []; - - const file = fileByItemId.get(item.id); - if (!file) return []; - - const { - change: { path }, - item: { version }, - } = file; - if (version === undefined) return []; - - const reviewedLatestVersion = reviewedFiles.get(path)?.has(version); - return reviewedLatestVersion ? item.id : []; - }), + items + .values() + .map((item) => { + const manuallyCollapsed = manualCollapseByItem.get(item.id); + if (manuallyCollapsed !== undefined) return manuallyCollapsed ? item.id : null; + + const file = fileByItemId.get(item.id); + if (!file) return null; + + const { + change: { path }, + item: { version }, + } = file; + if (version === undefined) return null; + + const reviewedLatestVersion = reviewedFiles.get(path)?.has(version); + return reviewedLatestVersion ? item.id : null; + }) + .filter((x) => x != null), ); const visibleAddressSpace = withoutFoldedHunks(addressSpace, hunkByKey, collapsedItems); @@ -1100,7 +1103,9 @@ const DiffContents: FC<{ new Set( projectSlice.selectors .selectCheckedAddresses(store.getState(), projectId) - .flatMap((address) => (address._tag === "Hunk" ? [hunkAddressIdentityKey(address)] : [])), + .values() + .map((address) => (address._tag === "Hunk" ? hunkAddressIdentityKey(address) : null)) + .filter((x) => x != null), ); const applyCheckedAddressGroups = ({ @@ -1112,8 +1117,11 @@ const DiffContents: FC<{ next: Set; addressesByKey: Map>>; }): void => { - const addressesForKeys = (keys: Set) => - Array.from(keys).flatMap((key) => addressesByKey.get(key) ?? []); + const addressesForKeys = (keys: Set): Array => + keys + .values() + .flatMap((key) => addressesByKey.get(key) ?? []) + .toArray(); dispatch( projectSlice.actions.checkAddresses({ @@ -1163,11 +1171,14 @@ const DiffContents: FC<{ }; const visibleHunkGroups = () => - visibleAddressSpace.items.flatMap((address) => { - const selection = hunkByKey.get(hunkAddressIdentityKey(address))?.selectedLines; - const lineAddresses = selection ? addressesForSelectedLines(selection, "line") : []; - return lineAddresses.length > 0 ? [{ address, lineAddresses }] : []; - }); + visibleAddressSpace.items + .values() + .map((address) => { + const selection = hunkByKey.get(hunkAddressIdentityKey(address))?.selectedLines; + const lineAddresses = selection ? addressesForSelectedLines(selection, "line") : null; + return lineAddresses && lineAddresses.length > 0 ? { address, lineAddresses } : null; + }) + .filter((x) => x != null); // Checkbox Shift-click extends persistent checked ranges. Shift-clicking the surrounding gutter // remains Pierre's active line-range gesture, unlike the whole-row shortcut on file/commit rows. @@ -1175,8 +1186,9 @@ const DiffContents: FC<{ const key = hunkAddressIdentityKey(address); const previous = shiftKey && lineCheckRangeAnchor.current !== null ? checkedHunkKeys() : null; if (previous && previous.size > 0) { - const groups = visibleHunkGroups(); - const orderedAddresses = groups.flatMap(({ lineAddresses }) => lineAddresses); + const orderedAddresses = visibleHunkGroups() + .flatMap(({ lineAddresses }) => lineAddresses) + .toArray(); const addressesByKey = new Map( orderedAddresses.map((lineAddress) => [hunkAddressIdentityKey(lineAddress), [lineAddress]]), ); @@ -1228,19 +1240,22 @@ const DiffContents: FC<{ return; } - const groups = visibleHunkGroups(); + const groups = visibleHunkGroups().toArray(); const addressesByKey = new Map( groups.map(({ address, lineAddresses }) => [hunkAddressIdentityKey(address), lineAddresses]), ); const state = store.getState(); const previous = new Set( - groups.flatMap(({ address, lineAddresses }) => - lineAddresses.every((lineAddress) => - projectSlice.selectors.selectAddressChecked(state, projectId, lineAddress), + groups + .values() + .map(({ address, lineAddresses }) => + lineAddresses.every((lineAddress) => + projectSlice.selectors.selectAddressChecked(state, projectId, lineAddress), + ) + ? hunkAddressIdentityKey(address) + : null, ) - ? [hunkAddressIdentityKey(address)] - : [], - ), + .filter((x) => x != null), ); const nextRange = checkedRangeFor({ orderedAddresses: groups.map(({ address }) => address), @@ -2053,11 +2068,13 @@ const Diff: FC<{ (address) => address._tag === "File" && addressEquals(address.parent, fileParent), ) ) { - const checkedChanges = sources.flatMap( - (source) => - changes.find((candidate) => source._tag === "File" && candidate.path === source.path) ?? - [], - ); + const checkedChanges = sources + .values() + .map((source) => + changes.find((candidate) => source._tag === "File" && candidate.path === source.path), + ) + .filter((x) => x != null) + .toArray(); if (checkedChanges.length !== sources.length) return; subjectChanges = checkedChanges; @@ -3010,6 +3027,7 @@ const UnappliedBranchDetails: FC = ({ ...branchListQueryOptions(projectId), select: (stacks) => stacks + .values() .flatMap((stack) => stack.branches) .find((listed) => listed.displayName === branchName && listed.reviewStatus === "merged") ?.review?.number ?? null, @@ -3283,8 +3301,12 @@ const FileDetails: FC<{ const canShowFiles = useCanShowFiles(); const filesVisible = canShowFiles && filesVisibleState; const { data: worktreeChanges } = useSuspenseQuery(changesInWorktreeQueryOptions(projectId)); - const filesItems = getChangesFileRowItems(worktreeChanges); - const changes = filesItems.flatMap((item) => (item._tag === "Change" ? [item.change] : [])); + const filesItems = getChangesFileRowItems(worktreeChanges).toArray(); + const changes = filesItems + .values() + .map((item) => (item._tag === "Change" ? item.change : null)) + .filter((x) => x != null) + .toArray(); const selectFile = (selection: string) => { setCursor("uncommitted", selection); diff --git a/apps/lite/ui/src/routes/project/$id/workspace/FilesTree.tsx b/apps/lite/ui/src/routes/project/$id/workspace/FilesTree.tsx index b605594310f..784e63fae0a 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/FilesTree.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/FilesTree.tsx @@ -448,7 +448,10 @@ export const FilesTree: FC< const rowByPath = new Map(rows.map((row) => [row.path, row])); // Conflicts have no change to commit or discard yet, so they never get checked. const conflictPaths = new Set( - rows.flatMap((row) => (row._tag === "File" && row.item._tag === "Conflict" ? [row.path] : [])), + rows + .values() + .filter((row) => row._tag === "File" && row.item._tag === "Conflict") + .map((row) => row.path), ); const checkable = (path: string) => !conflictPaths.has(path); const selectedRow = selection === null ? undefined : rowByPath.get(selection); @@ -487,9 +490,14 @@ export const FilesTree: FC< projectId, ); return new Set( - checkedAddresses.flatMap((address) => - address._tag === "File" && addressEquals(address.parent, fileParent) ? address.path : [], - ), + checkedAddresses + .values() + .map((address) => + address._tag === "File" && addressEquals(address.parent, fileParent) + ? address.path + : null, + ) + .filter((x) => x != null), ); }; diff --git a/apps/lite/ui/src/routes/project/$id/workspace/Sidebar.tsx b/apps/lite/ui/src/routes/project/$id/workspace/Sidebar.tsx index a2c3a581aa9..bd04d97a412 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/Sidebar.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/Sidebar.tsx @@ -106,11 +106,6 @@ export const Sidebar: FC<{ select: (cfg) => cfg.autoFetchFrequency, }); const { data: workspaceFetchStatus } = useQuery(workspaceFetchStatusQueryOptions(projectId)); - const rebaseUpdates = - headInfo?.stacks.flatMap((stack): Array => { - const relativeTo = stackBottomRelativeTo(stack); - return relativeTo ? [{ kind: "rebase", selector: relativeTo }] : []; - }) ?? []; const { isPending: isWorkspaceIntegrateUpstreamPending, mutate: workspaceIntegrateUpstream } = useWorkspaceIntegrateUpstream(); const { isFetching: isWorkspaceFetchFromRemotesPending, refetch: workspaceFetchFromRemotes } = @@ -130,7 +125,13 @@ export const Sidebar: FC<{ }); }; const updateWorkspace = () => { - workspaceIntegrateUpstream({ projectId, updates: rebaseUpdates, dryRun: false }); + const rebaseUpdates = (headInfo?.stacks ?? []) + .values() + .map(stackBottomRelativeTo) + .filter((relativeTo) => relativeTo != null) + .map((relativeTo): BottomUpdate => ({ kind: "rebase", selector: relativeTo })); + + workspaceIntegrateUpstream({ projectId, updates: rebaseUpdates.toArray(), dryRun: false }); }; // Only an update advances the stored target, so there is work to do exactly diff --git a/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/WorkspaceLists.tsx b/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/WorkspaceLists.tsx index 05857c16d45..1ac1108ff4b 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/WorkspaceLists.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/WorkspaceLists.tsx @@ -93,6 +93,7 @@ import { buildCommitTargetComboboxItems, selectCommitTargetComboboxItem, } from "./commitTargetComboboxItems.ts"; +import { reverseValues } from "#ui/iterator.ts"; const uncommittedChangesHeadingId = "uncommitted-changes-heading"; @@ -626,14 +627,14 @@ const StackC: FC<{ role="group" aria-label="Stack" > - {stack.segments.flatMap((segment, index) => { + {stack.segments.map((segment, index) => { const key = segment.refName ? JSON.stringify(segment.refName.fullNameBytes) : segment.commits[0]?.id; // A segment is supposed to always either have a branch reference or at least one commit, // however with the current API this may not be the case e.g. detached HEAD. - if (key === undefined) return []; + if (key === undefined) return null; const downstackPushStatus = assert(downstackPushStatuses[index]); const pushActivity: PushActivity = @@ -764,17 +765,19 @@ const Stacks: FC<{ data-preview-source={activeList === "applied"} ref={useMergedRefs(hotkeysRef, useAutofocusScope(activeList === "applied"))} > - {(headInfo?.stacks.toReversed() ?? []).map((stack) => ( - - ))} + {reverseValues(headInfo?.stacks ?? []) + .map((stack) => ( + + )) + .toArray()} ); @@ -830,11 +833,14 @@ export const WorkspaceLists: FC< commitAmend({ projectId, commitId, - changes: worktreeChanges.changes.flatMap((change) => - checkedUncommittedFilePaths.size === 0 || checkedUncommittedFilePaths.has(change.path) - ? [createDiffSpec(change, [])] - : [], - ), + changes: worktreeChanges.changes + .values() + .filter( + (change) => + checkedUncommittedFilePaths.size === 0 || checkedUncommittedFilePaths.has(change.path), + ) + .map((change) => createDiffSpec(change, [])) + .toArray(), changesSource: { type: "head" }, dryRun: false, }); @@ -872,20 +878,28 @@ export const WorkspaceLists: FC< dispatch( projectSlice.actions.checkAddresses({ projectId, - addresses: Array.from(checkedCommits).flatMap((commitId) => { - const ctx = headInfoIndex?.commitContextByCommitId(commitId); - return ctx ? commitAddress({ commitId, changeId: ctx.commit.changeId }) : []; - }), + addresses: checkedCommits + .values() + .map((commitId) => { + const ctx = headInfoIndex?.commitContextByCommitId(commitId); + return ctx ? commitAddress({ commitId, changeId: ctx.commit.changeId }) : null; + }) + .filter((x) => x != null) + .toArray(), checked: true, }), ); dispatch( projectSlice.actions.checkAddresses({ projectId, - addresses: Array.from(uncheckedCommits).flatMap((commitId) => { - const ctx = headInfoIndex?.commitContextByCommitId(commitId); - return ctx ? commitAddress({ commitId, changeId: ctx.commit.changeId }) : []; - }), + addresses: uncheckedCommits + .values() + .map((commitId) => { + const ctx = headInfoIndex?.commitContextByCommitId(commitId); + return ctx ? commitAddress({ commitId, changeId: ctx.commit.changeId }) : null; + }) + .filter((x) => x != null) + .toArray(), checked: false, }), ); diff --git a/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/commitTargetComboboxItems.ts b/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/commitTargetComboboxItems.ts index 5aca3021551..685e1a5fc00 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/commitTargetComboboxItems.ts +++ b/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/commitTargetComboboxItems.ts @@ -3,6 +3,7 @@ import { commitTitle } from "#ui/commit.ts"; import { addressEquals, type Address } from "#ui/addresses.ts"; import type { RefInfo } from "@gitbutler/but-sdk"; import type { CommitTargetComboboxItem } from "../CommitForm.tsx"; +import { reverseValues } from "#ui/iterator.ts"; export const buildCommitTargetComboboxItems = ({ headInfo, @@ -29,23 +30,23 @@ export const buildCommitTargetComboboxItems = ({ ] satisfies Array) : []), ...(headInfo - ? headInfo.stacks.toReversed().flatMap( - (stack): Array => - stack.segments.flatMap((segment): Array => { - const refName = segment.refName; - if (!refName) return []; + ? reverseValues(headInfo.stacks).flatMap( + (stack): IteratorObject => + stack.segments + .values() + .map(({ refName }): CommitTargetComboboxItem | null => { + if (!refName) return null; - return [ - { + return { label: refName.displayName, address: { _tag: "Branch", branchRef: refName.fullNameBytes }, relativeTo: { type: "referenceBytes", subject: refName.fullNameBytes, }, - }, - ]; - }), + }; + }) + .filter((x) => x != null), ) : []), ]; diff --git a/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/useStackMenuItems.ts b/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/useStackMenuItems.ts index 8648d3faffd..f16ba883749 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/useStackMenuItems.ts +++ b/apps/lite/ui/src/routes/project/$id/workspace/WorkspaceLists/useStackMenuItems.ts @@ -29,11 +29,15 @@ export const useStackMenuItems = (projectId: string, stack: Stack): Array segment.refName !== null).length; // Only a segment with a branch reference and commits to hide can be folded; // fold state is keyed by that reference. - const foldableRefs = stack.segments.flatMap((segment) => - segment.refName !== null && segment.commits.length > 0 - ? [decodeBytes(segment.refName.fullNameBytes)] - : [], - ); + const foldableRefs = stack.segments + .values() + .map((segment) => + segment.refName !== null && segment.commits.length > 0 + ? decodeBytes(segment.refName.fullNameBytes) + : null, + ) + .filter((x) => x != null) + .toArray(); // A plain boolean, so this re-renders only when the stack crosses between // fully unfolded and not. const anyFolded = useAppSelector((state) => diff --git a/apps/lite/ui/src/routes/project/$id/workspace/applied-address-space.ts b/apps/lite/ui/src/routes/project/$id/workspace/applied-address-space.ts index 34fb2f64585..81540d80bbb 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/applied-address-space.ts +++ b/apps/lite/ui/src/routes/project/$id/workspace/applied-address-space.ts @@ -7,6 +7,7 @@ import { type Address, } from "#ui/addresses.ts"; import { decodeBytes } from "#ui/api/bytes.ts"; +import { reverseValues } from "#ui/iterator.ts"; import { getOperations, type TransferKind } from "#ui/operations/operation.ts"; import { getTransferKind, type PendingOperation } from "#ui/operations/pending-operation.ts"; import { buildIndexByKey, type AddressSpace } from "#ui/workspace/address-space.ts"; @@ -34,24 +35,28 @@ export const buildAppliedAddressSpace = ({ foldedSegments: Record; }): AddressSpace
=> { const allItems = (): Array
=> - headInfo?.stacks.toReversed().flatMap((stack) => - stack.segments.flatMap((segment): Array
=> { - // Matches what WorkspaceLists renders: a folded segment shows a stub - // in place of its commits, so they are not navigable. - const folded = - segment.refName !== null && - foldedSegments[decodeBytes(segment.refName.fullNameBytes)] === true; + reverseValues(headInfo?.stacks ?? []) + .flatMap((stack) => + stack.segments.flatMap((segment): Array
=> { + // Matches what WorkspaceLists renders: a folded segment shows a stub + // in place of its commits, so they are not navigable. + const folded = + segment.refName !== null && + foldedSegments[decodeBytes(segment.refName.fullNameBytes)] === true; - return [ - ...(segment.refName ? [branchAddress({ branchRef: segment.refName.fullNameBytes })] : []), - ...(folded - ? [] - : segment.commits.map((commit) => - commitAddress({ commitId: commit.id, changeId: commit.changeId }), - )), - ]; - }), - ) ?? []; + return [ + ...(segment.refName + ? [branchAddress({ branchRef: segment.refName.fullNameBytes })] + : []), + ...(folded + ? [] + : segment.commits.map((commit) => + commitAddress({ commitId: commit.id, changeId: commit.changeId }), + )), + ]; + }), + ) + .toArray(); /** * While an operation is waiting for its target, only its sources and the diff --git a/apps/lite/ui/src/routes/project/$id/workspace/diff-line-target.ts b/apps/lite/ui/src/routes/project/$id/workspace/diff-line-target.ts index 45ee5d9c68c..bc4d778b21d 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/diff-line-target.ts +++ b/apps/lite/ui/src/routes/project/$id/workspace/diff-line-target.ts @@ -43,9 +43,10 @@ export const diffLineTargetFromElement = ({ const line = diffLineFromElement(element); if (!line) return null; - const [number] = LINE_NUMBER_ATTRIBUTES.flatMap( - (attribute) => element.getAttribute(attribute) ?? [], - ); + const [number] = LINE_NUMBER_ATTRIBUTES.values() + .map((attr) => element.getAttribute(attr)) + .filter((x) => x != null) + .take(1); const lineNumber = Number.parseInt(number ?? "", 10); if (!Number.isFinite(lineNumber)) return null; diff --git a/apps/lite/ui/src/routes/project/$id/workspace/diff-minimap.ts b/apps/lite/ui/src/routes/project/$id/workspace/diff-minimap.ts index 82f1f15fdd4..d6b9d3e0c36 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/diff-minimap.ts +++ b/apps/lite/ui/src/routes/project/$id/workspace/diff-minimap.ts @@ -547,14 +547,22 @@ export const getMinimapOverlays = ({ { side: selection.side, line: selection.start }, { side: selection.endSide, line: selection.end }, ]; - const starts = endpoints.flatMap(({ side, line }) => locate(side, line) ?? []); + const starts = endpoints + .values() + .map(({ side, line }) => locate(side, line)) + .filter((x) => x != null) + .toArray(); if (starts.length === 0) continue; - const ends = endpoints.flatMap(({ side, line }) => { - const start = locate(side, line); - if (start === null) return []; - return locate(side, line + 1) ?? start; - }); + const ends = endpoints + .values() + .map(({ side, line }) => { + const start = locate(side, line); + if (start === null) return null; + + return locate(side, line + 1) ?? start; + }) + .filter((x) => x != null); const top = Math.min(...starts); const bottom = Math.max(...ends); band = { top, height: Math.max(bottom - top, 0) }; diff --git a/apps/lite/ui/src/routes/project/$id/workspace/file-row.ts b/apps/lite/ui/src/routes/project/$id/workspace/file-row.ts index 08ccc3fdafa..a0e818377f0 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/file-row.ts +++ b/apps/lite/ui/src/routes/project/$id/workspace/file-row.ts @@ -2,6 +2,7 @@ import { getDependencyCommitIds, getHunkDependencyDiffsByPath } from "#ui/hunk.t import { compareFilePaths } from "#ui/file-order.ts"; import { buildFileTreeRows, type FileDisplayMode, type FileTreeRow } from "./file-tree.ts"; import type { TreeChange, WorktreeChanges } from "@gitbutler/but-sdk"; +import { iteratorConcat } from "#ui/iterator.ts"; type ChangeFileRowItem = { change: TreeChange; @@ -37,25 +38,26 @@ export const conflictFileRowItem = ({ modifiedAtMs, }); -export const getChangesFileRowItems = (worktreeChanges: WorktreeChanges): Array => { +export const getChangesFileRowItems = ( + worktreeChanges: WorktreeChanges, +): IteratorObject => { const hunkDependencyDiffsByPath = getHunkDependencyDiffsByPath( worktreeChanges.dependencies?.diffs ?? [], ); // Conflicted files are kept out of `changes` until resolved, but they still // sit on disk, so they carry a modification time like any other row. - const conflicts = worktreeChanges.ignoredChanges.flatMap((change) => - change.status === "Conflict" - ? [ - conflictFileRowItem({ - path: change.path, - modifiedAtMs: worktreeChanges.modificationTimes[change.path] ?? null, - }), - ] - : [], - ); + const conflicts = worktreeChanges.ignoredChanges + .values() + .filter((change) => change.status === "Conflict") + .map((change) => + conflictFileRowItem({ + path: change.path, + modifiedAtMs: worktreeChanges.modificationTimes[change.path] ?? null, + }), + ); - const changes = worktreeChanges.changes.map((change) => { + const changes = worktreeChanges.changes.values().map((change) => { const hunkDependencyDiffs = hunkDependencyDiffsByPath.get(change.path); const dependencyCommitIds = hunkDependencyDiffs ? getDependencyCommitIds({ hunkDependencyDiffs }) @@ -69,7 +71,7 @@ export const getChangesFileRowItems = (worktreeChanges: WorktreeChanges): Array< }); }); - return [...conflicts, ...changes]; + return iteratorConcat(conflicts, changes); }; /** @@ -90,9 +92,11 @@ export const buildUncommittedFileRows = ({ recentFirst: boolean; }): Array> => buildFileTreeRows({ - items: (worktreeChanges ? getChangesFileRowItems(worktreeChanges) : []).filter((item) => - pathMatchesFilter(item.path, filter), - ), + items: worktreeChanges + ? getChangesFileRowItems(worktreeChanges) + .filter((item) => pathMatchesFilter(item.path, filter)) + .toArray() + : [], mode, collapsedDirectories, compare: recentFirst ? compareRecentFirst : undefined, diff --git a/apps/lite/ui/src/routes/project/$id/workspace/useCheckedActions.ts b/apps/lite/ui/src/routes/project/$id/workspace/useCheckedActions.ts index 7f314ebbf50..b1ae2d45ce1 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/useCheckedActions.ts +++ b/apps/lite/ui/src/routes/project/$id/workspace/useCheckedActions.ts @@ -242,12 +242,16 @@ export const useCheckedActions = ({ .getQueryData(changesInWorktreeQueryOptions(projectId).queryKey) ?.changes.map((change) => [change.path, change]), ); - const hunks = checkedAddresses.flatMap((address) => { - const change = changesByPath.get(address.parent.path); - return change - ? [{ pathBytes: change.pathBytes, hunkHeader: address.hunkHeader }] - : []; - }); + const hunks = checkedAddresses + .values() + .map((address) => { + const change = changesByPath.get(address.parent.path); + return change + ? { pathBytes: change.pathBytes, hunkHeader: address.hunkHeader } + : null; + }) + .filter((x) => x != null) + .toArray(); if (hunks.length !== checkedAddresses.length) return; startAbsorb({ diff --git a/apps/lite/ui/src/routes/project/$id/workspace/useUpstreamList.ts b/apps/lite/ui/src/routes/project/$id/workspace/useUpstreamList.ts index 0cc7fd240d1..e9637cdae2e 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/useUpstreamList.ts +++ b/apps/lite/ui/src/routes/project/$id/workspace/useUpstreamList.ts @@ -120,34 +120,39 @@ type WorkspaceStackBranches = { }; /** The named workspace branches per stack, split by their integration state. */ -const workspaceStackBranches = (headInfo: RefInfo | undefined): Array => - headInfo?.stacks.flatMap((stack): Array => { - const stackKey = - stack.segments.find((segment) => segment.refName !== null)?.refName?.displayName ?? ""; - const branches = stack.segments.flatMap( - (segment): Array => - segment.refName !== null - ? [ - { +const workspaceStackBranches = (headInfo: RefInfo): Array => + headInfo.stacks + .values() + .map((stack): WorkspaceStackBranches | null => { + const stackKey = + stack.segments.find((segment) => segment.refName !== null)?.refName?.displayName ?? ""; + + const branches = stack.segments + .values() + .map((segment): UpstreamBranchItem | null => + segment.refName !== null + ? { type: "branch", name: segment.refName.displayName, prNumber: segment.metadata?.review.pullRequest ?? null, integrated: segment.pushStatus === "integrated", stackKey, - }, - ] - : [], - ); - return branches.length > 0 - ? [ - { + } + : null, + ) + .filter((x) => x != null) + .toArray(); + + return branches.length > 0 + ? { base: stack.base, integrated: branches.filter((branch) => branch.integrated), unintegrated: branches.filter((branch) => !branch.integrated), - }, - ] - : []; - }) ?? []; + } + : null; + }) + .filter((x) => x != null) + .toArray(); /** * Interleave the target-commit line with the workspace's branches: each @@ -337,7 +342,7 @@ export const useUpstreamList = (projectId: string): UpstreamListData => { }; } - const stacks = workspaceStackBranches(headInfo); + const stacks = headInfo ? workspaceStackBranches(headInfo) : []; const commits = targetCommits.map(asItem); const { items, incomingItemCount, trailingRun } = buildItems( commits, From 54ae0519fa419988b51298758a1cd2639b78f376 Mon Sep 17 00:00:00 2001 From: "Sam A. Horvath-Hunt" Date: Fri, 28 Aug 2026 13:32:25 +0100 Subject: [PATCH 2/2] Non-reactive focused scope lookup The previous implementation triggered a re-render of the PageBody component that consumes the calling hook. In certain very large repos this would cause the UI to lock up on every focus change. Whilst this performance problem was severely compounded by other performance issues to be addressed in due course, this is still a worthy fix. --- apps/lite/ui/src/focus.ts | 18 ------------------ .../src/routes/project/$id/workspace/Page.tsx | 5 +---- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/apps/lite/ui/src/focus.ts b/apps/lite/ui/src/focus.ts index ffd3003c45e..04503c0941e 100644 --- a/apps/lite/ui/src/focus.ts +++ b/apps/lite/ui/src/focus.ts @@ -1,4 +1,3 @@ -import { useSyncExternalStore } from "react"; import type { FocusScope } from "#ui/focus-scopes.ts"; const allFocusScopes: Record = { @@ -16,20 +15,3 @@ const allFocusScopes: Record = { * type remains there because its type-only import is erased and does not create that cycle. */ export const isFocusScope = (id: string): id is FocusScope => Object.hasOwn(allFocusScopes, id); - -const subscribeToFocus = (onStoreChange: () => void) => { - window.addEventListener("focusin", onStoreChange); - window.addEventListener("focusout", onStoreChange); - - return () => { - window.removeEventListener("focusin", onStoreChange); - window.removeEventListener("focusout", onStoreChange); - }; -}; - -export const useActiveElement = () => - useSyncExternalStore( - subscribeToFocus, - () => document.activeElement, - () => null, - ); diff --git a/apps/lite/ui/src/routes/project/$id/workspace/Page.tsx b/apps/lite/ui/src/routes/project/$id/workspace/Page.tsx index aa5ca23b573..04bdfff6b5a 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/Page.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/Page.tsx @@ -64,7 +64,6 @@ import { buildUncommittedFileRows } from "./file-row.ts"; import { fileTreeAddressSpace, selectedFilePath } from "./file-tree.ts"; import { useFileDisplayMode } from "./useFileDisplayMode.ts"; import styles from "./Page.module.css"; -import { useActiveElement } from "#ui/focus.ts"; import { ApplyBranchPicker } from "./ApplyBranchPicker.tsx"; import { BranchPicker } from "./BranchPicker.tsx"; import { CommandPalette } from "./CommandPalette.tsx"; @@ -99,8 +98,6 @@ const useWorkspaceHotkeys = (projectId: string) => { const detailsFullWindow = useAppSelector(interfaceSlice.selectors.selectDetailsFullWindow); const dialog = useAppSelector(interfaceSlice.selectors.selectDialogState); const canShowFiles = useCanShowFiles(); - const activeElement = useActiveElement(); - const focusedFocusScope = getFocusedScope(activeElement); const noOperationPending = useAppSelector( (state) => projectSlice.selectors.selectPendingOperation(state, projectId)._tag === "None", ); @@ -173,7 +170,7 @@ const useWorkspaceHotkeys = (projectId: string) => { { hotkey: workspaceHotkeys.toggleFiles.hotkey, callback: () => { - if (focusedFocusScope === "files" && getFilesVisible()) + if (getFocusedScope(document.activeElement) === "files" && getFilesVisible()) focusScope(detailsFullWindow ? "diff" : "sidebar"); dispatch(projectSlice.actions.toggleFiles({ projectId }));