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/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/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/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 })); 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,