Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions apps/lite/ui/src/checking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
};

Expand Down
18 changes: 0 additions & 18 deletions apps/lite/ui/src/focus.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useSyncExternalStore } from "react";
import type { FocusScope } from "#ui/focus-scopes.ts";

const allFocusScopes: Record<FocusScope, null> = {
Expand All @@ -16,20 +15,3 @@ const allFocusScopes: Record<FocusScope, null> = {
* 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,
);
32 changes: 18 additions & 14 deletions apps/lite/ui/src/hunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,20 +448,24 @@ export const hunkSelectionForLineNavigation = <T extends HunkLineSelection>({
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,
Expand Down
13 changes: 13 additions & 0 deletions apps/lite/ui/src/iterator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Ponyfill of `Iterator.concat`, unavailable until Node.js v26. */
export function iteratorConcat<T>(...iterables: Array<Iterable<T>>): IteratorObject<T> {
return (function* (): Generator<T> {
for (const iterable of iterables) yield* iterable;
})();
}

/** Like `Array.prototype.values`, but iterates in reverse order. */
export function* reverseValues<T>(array: Array<T>): Generator<T> {
for (let index = array.length - 1; index >= 0; index--)
// oxlint-disable-next-line typescript/no-non-null-assertion
yield array[index]!;
}
3 changes: 0 additions & 3 deletions apps/lite/ui/src/native-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NativeMenuItem>>,
): Array<NativeMenuItem> =>
Expand Down
28 changes: 15 additions & 13 deletions apps/lite/ui/src/project-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ForgeReview>(options.queryKey)?.mergedAt != null
? []
: [client.fetchQuery({ ...options, staleTime: Number.POSITIVE_INFINITY })];
}),
reviewIds
.values()
.map((reviewId) => getReviewQueryOptions({ projectId, reviewId }))
.filter((options) => client.getQueryData<ForgeReview>(options.queryKey)?.mergedAt == null)
.map((options) => client.fetchQuery({ ...options, staleTime: Number.POSITIVE_INFINITY })),
);
};

Expand Down
4 changes: 2 additions & 2 deletions apps/lite/ui/src/projects/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,15 @@ 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<Address, { _tag: "Commit" | "File" | "Hunk" }>;
export type CheckableAddress = Extract<Address, { _tag: "Commit" | "File" | "Hunk" }>;

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}`;
Expand Down
152 changes: 91 additions & 61 deletions apps/lite/ui/src/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,20 @@ export const useStateReconciler = (projectId: string): void => {
parent: FileParent;
path: string;
};
const checkedFiles = checkedAddresses.flatMap<FileScopedCheckedAddress>((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",
Expand All @@ -111,9 +115,11 @@ export const useStateReconciler = (projectId: string): void => {
const { mutate: pruneReviewedFiles } = usePruneReviewedFiles();
const reconcileCheckedUncommittedFiles = useEffectEvent(
(worktreeChangesByPath: Map<string, TreeChange>) => {
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(
Expand All @@ -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<string, Map<string, TreeChange>>,
) => {
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(
Expand All @@ -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<string, Set<string>>) => {
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(
Expand Down Expand Up @@ -238,16 +256,17 @@ export const useStateReconciler = (projectId: string): void => {
),
combine: (results): Map<string, Map<string, TreeChange>> =>
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(() => {
Expand All @@ -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(() => {
Expand All @@ -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 =
Expand All @@ -295,27 +318,34 @@ 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 }) =>
treeChangeDiffsQueryOptions({ projectId, change }),
),
combine: (results): Set<string> =>
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<string>) => {
Expand Down
Loading
Loading