diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 13bfe591375..abee1c35453 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@anthropic-ai/sdk": "^0.59.0", "@gitbutler/core": "workspace:*", - "@gitbutler/design-core": "1.6.1", + "@gitbutler/design-core": "1.7.1", "@gitbutler/shared": "workspace:*", "@gitbutler/svelte-comment-injector": "workspace:*", "@gitbutler/ui": "workspace:*", diff --git a/apps/desktop/src/components/BranchCard.svelte b/apps/desktop/src/components/BranchCard.svelte index e5651361bba..f395b6ebb12 100644 --- a/apps/desktop/src/components/BranchCard.svelte +++ b/apps/desktop/src/components/BranchCard.svelte @@ -71,6 +71,7 @@ buttons?: Snippet; branchContent: Snippet; codegenRow?: Snippet; + changedFiles?: Snippet; } interface PrBranchProps extends BranchCardProps { @@ -204,6 +205,7 @@ menu={args.menu} conflicts={args.isConflicted} {showPrCreation} + changedFiles={args.changedFiles} dragArgs={{ disabled: args.isConflicted || (args.type === 'stack-branch' && args.applied === false), label: branchName, diff --git a/apps/desktop/src/components/BranchCommitList.svelte b/apps/desktop/src/components/BranchCommitList.svelte index 43861b58905..d860e07cd53 100644 --- a/apps/desktop/src/components/BranchCommitList.svelte +++ b/apps/desktop/src/components/BranchCommitList.svelte @@ -6,7 +6,8 @@ import CommitLineOverlay from '$components/CommitLineOverlay.svelte'; import CommitRow from '$components/CommitRow.svelte'; import Dropzone from '$components/Dropzone.svelte'; - + import LazyList from '$components/LazyList.svelte'; + import NestedChangedFiles from '$components/NestedChangedFiles.svelte'; import ReduxResult from '$components/ReduxResult.svelte'; import UpstreamCommitsAction from '$components/UpstreamCommitsAction.svelte'; import { isLocalAndRemoteCommit, isUpstreamCommit } from '$components/lib'; @@ -19,6 +20,7 @@ createCommitDropHandlers, type DzCommitData } from '$lib/commits/dropHandler'; + import { findEarliestConflict } from '$lib/commits/utils'; import { projectRunCommitHooks } from '$lib/config/config'; import { draggableCommitV3 } from '$lib/dragging/draggable'; import { DROPZONE_REGISTRY } from '$lib/dragging/registry'; @@ -28,6 +30,7 @@ } from '$lib/dragging/stackingReorderDropzoneManager'; import { DEFAULT_FORGE_FACTORY } from '$lib/forge/forgeFactory.svelte'; import { HOOKS_SERVICE } from '$lib/hooks/hooksService'; + import { createCommitSelection } from '$lib/selection/key'; import { STACK_SERVICE } from '$lib/stacks/stackService.svelte'; import { combineResults } from '$lib/state/helpers'; import { UI_STATE } from '$lib/state/uiState.svelte'; @@ -54,6 +57,7 @@ handleUncommit: (commitId: string, branchName: string) => Promise; startEditingCommitMessage: (branchName: string, commitId: string) => void; onselect?: () => void; + onCommitFileClick?: (commitId: string, path: string, index: number) => void; } let { @@ -68,7 +72,8 @@ active, handleUncommit, startEditingCommitMessage, - onselect + onselect, + onCommitFileClick }: Props = $props(); const stackService = inject(STACK_SERVICE); @@ -266,152 +271,200 @@ {@render commitReorderDz(stackingReorderDropzoneManager.top(branchName))} - {#each localAndRemoteCommits as commit, i (commit.id)} - {@const first = i === 0} - {@const last = i === localAndRemoteCommits.length - 1} - {@const commitId = commit.id} - {@const selected = commit.id === selectedCommitId && branchName === selectedBranchName} - {#if isCommitting} - - { - projectState.exclusiveAction.set({ - type: 'commit', - stackId, - branchName, - parentCommitId: commitId - }); - }} - /> - {/if} - {@const dzCommit: DzCommitData = { - id: commit.id, - isRemote: isUpstreamCommit(commit), - isIntegrated: isLocalAndRemoteCommit(commit) && commit.state.type === 'Integrated', - hasConflicts: isLocalAndRemoteCommit(commit) && commit.hasConflicts, - }} - {@const { amendHandler, squashHandler, hunkHandler } = createCommitDropHandlers({ - projectId, - stackId, - stackService, - hooksService, - uiState, - commit: dzCommit, - runHooks: $runHooks, - okWithForce: true, - onCommitIdChange: (newId) => { - if (stackId) { - const previewOpen = selection.current?.previewOpen ?? false; - uiState.lane(stackId).selection.set({ branchName, commitId: newId, previewOpen }); + + {#snippet template(commit, { first, last })} + {@const commitId = commit.id} + {@const selected = commit.id === selectedCommitId && branchName === selectedBranchName} + {#if isCommitting} + + { + projectState.exclusiveAction.set({ + type: 'commit', + stackId, + branchName, + parentCommitId: commitId + }); + }} + /> + {/if} + {@const dzCommit: DzCommitData = { + id: commit.id, + isRemote: isUpstreamCommit(commit), + isIntegrated: isLocalAndRemoteCommit(commit) && commit.state.type === 'Integrated', + hasConflicts: isLocalAndRemoteCommit(commit) && commit.hasConflicts + }} + {@const { amendHandler, squashHandler, hunkHandler } = createCommitDropHandlers({ + projectId, + stackId, + stackService, + hooksService, + uiState, + commit: dzCommit, + runHooks: $runHooks, + okWithForce: true, + onCommitIdChange: (newId) => { + if (stackId) { + const previewOpen = selection.current?.previewOpen ?? false; + uiState.lane(stackId).selection.set({ branchName, commitId: newId, previewOpen }); + } } - } - })} - {@const tooltip = commitStatusLabel(commit.state.type)} - - {#snippet overlay({ hovered, activated, handler })} - {@const label = - handler instanceof AmendCommitWithChangeDzHandler || - handler instanceof AmendCommitWithHunkDzHandler - ? 'Amend' - : 'Squash'} - - {/snippet} -
+ {#snippet overlay({ hovered, activated, handler })} + {@const label = + handler instanceof AmendCommitWithChangeDzHandler || + handler instanceof AmendCommitWithHunkDzHandler + ? 'Amend' + : 'Squash'} + + {/snippet} +
+ handleCommitClick(commit.id, false)} + disableCommitActions={false} + editable={!!stackId} + > + {#snippet menu({ rightClickTrigger })} + {@const data = { stackId, - { - id: commitId, - isRemote: !!branchDetails.remoteTrackingBranch, - hasConflicts: isLocalAndRemoteCommit(commit) && commit.hasConflicts, - isIntegrated: - isLocalAndRemoteCommit(commit) && commit.state.type === 'Integrated' - }, - false, - branchName - ) - : undefined, - dropzoneRegistry, - dragStateService - }} - > - handleUncommit(commit.id, branchName), + onEditMessageClick: () => startEditingCommitMessage(branchName, commit.id) + }} + + {/snippet} + + {#snippet changedFiles()} + {@const changesQuery = stackService.commitChanges(projectId, commitId)} + + + {#snippet children(changesResult)} + {@const commitsQuery = stackId + ? stackService.commits(projectId, stackId, branchName) + : undefined} + {@const commits = commitsQuery?.response || []} + {@const firstConflictedCommitId = findEarliestConflict(commits)?.id} + + + !(change.path in (changesResult.conflictEntries?.entries ?? {})) + )} + stats={changesResult.stats} + conflictEntries={changesResult.conflictEntries} + ancestorMostConflictedCommitId={firstConflictedCommitId} + autoselect + allowUnselect={false} + onselect={(change, index) => { + // Ensure the commit is selected so the preview shows it + const currentSelection = laneState.selection.current; + if ( + currentSelection?.commitId !== commitId || + currentSelection?.branchName !== branchName + ) { + laneState.selection.set({ + branchName, + commitId, + upstream: false, + previewOpen: true + }); + } + onCommitFileClick?.(commitId, change.path, index); + }} + /> + {/snippet} + + {/snippet} + +
+ + {@render commitReorderDz( + stackingReorderDropzoneManager.belowCommit(branchName, commit.id) + )} + {#if isCommitting && last} + handleCommitClick(commit.id, false)} - disableCommitActions={false} - editable={!!stackId} - > - {#snippet menu({ rightClickTrigger })} - {@const data = { + {last} + selected={exclusiveAction?.type === 'commit' && + exclusiveAction.parentCommitId === branchDetails.baseCommit && + commitAction?.branchName === branchName} + onclick={() => { + projectState.exclusiveAction.set({ + type: 'commit', stackId, - commitId, - commitMessage: commit.message, - commitStatus: commit.state.type, - commitUrl: forge.current.commitUrl(commitId), - onUncommitClick: () => handleUncommit(commit.id, branchName), - onEditMessageClick: () => startEditingCommitMessage(branchName, commit.id) - }} - - {/snippet} - -
-
- {@render commitReorderDz( - stackingReorderDropzoneManager.belowCommit(branchName, commit.id) - )} - {#if isCommitting && last} - { - projectState.exclusiveAction.set({ - type: 'commit', - stackId, - branchName, - parentCommitId: branchDetails.baseCommit - }); - }} - /> - {/if} - {/each} + branchName, + parentCommitId: branchDetails.baseCommit + }); + }} + /> + {/if} + {/snippet} +
{/if} {/snippet} diff --git a/apps/desktop/src/components/BranchExplorer.svelte b/apps/desktop/src/components/BranchExplorer.svelte index d927082f097..ee93beaf765 100644 --- a/apps/desktop/src/components/BranchExplorer.svelte +++ b/apps/desktop/src/components/BranchExplorer.svelte @@ -1,6 +1,6 @@
; + changedFiles?: Snippet; onclick?: () => void; }; @@ -74,7 +74,6 @@ selected, opacity, borderTop, - isOpen, disabled, hasConflicts, active, @@ -82,6 +81,7 @@ gerritReviewUrl, onclick, menu, + changedFiles, ...args }: Props = $props(); @@ -104,96 +104,113 @@ -
onclick?.(), - focusable: true - }} -> - {#if selected} -
- {/if} - - {#if !selected && !args.disableCommitActions} -
- -
- {/if} - - - -
- {#if hasConflicts} -
- -
+
+
onclick?.(), + focusable: true + }} + > + {#if selected} +
{/if} - {#if author} -
- + {#if !selected && !args.disableCommitActions} +
+
{/if} -
- - {#if gerritReviewUrl} - {@const reviewId = extractReviewId(gerritReviewUrl)} - {#if reviewId} - -
- {reviewId} -
+ + +
+ {#if hasConflicts} +
+ +
+ {/if} + + {#if author} +
+ +
+ {/if} + +
+ + {#if gerritReviewUrl} + {@const reviewId = extractReviewId(gerritReviewUrl)} + {#if reviewId} + +
+ {reviewId} +
+ {/if} {/if} +
+ + {#if !args.disableCommitActions} + {@render menu?.({ rightClickTrigger: container })} {/if}
- - {#if !args.disableCommitActions} - {@render menu?.({ rightClickTrigger: container })} - {/if}
+ + {#if selected && changedFiles} +
+ + {@render changedFiles()} + +
+ {/if}
diff --git a/apps/desktop/src/components/FileList.svelte b/apps/desktop/src/components/FileList.svelte index 9db5d4afba7..aae4fc025b8 100644 --- a/apps/desktop/src/components/FileList.svelte +++ b/apps/desktop/src/components/FileList.svelte @@ -3,7 +3,7 @@ import EditPatchConfirmModal from '$components/EditPatchConfirmModal.svelte'; import FileListItemWrapper from '$components/FileListItemWrapper.svelte'; import FileTreeNode from '$components/FileTreeNode.svelte'; - import LazyloadContainer from '$components/LazyloadContainer.svelte'; + import LazyList from '$components/LazyList.svelte'; import { ACTION_SERVICE } from '$lib/actions/actionService.svelte'; import { AI_SERVICE } from '$lib/ai/service'; import { projectAiGenEnabled } from '$lib/config/config'; @@ -23,13 +23,12 @@ import { selectFilesInList, updateSelection } from '$lib/selection/fileSelectionUtils'; import { type SelectionId } from '$lib/selection/key'; import { SETTINGS } from '$lib/settings/userSettings'; - import { chunk } from '$lib/utils/array'; import { inject, injectOptional } from '@gitbutler/core/context'; import { AsyncButton, FileListItem, TestId } from '@gitbutler/ui'; import { FOCUS_MANAGER } from '@gitbutler/ui/focus/focusManager'; import { focusable } from '@gitbutler/ui/focus/focusable'; - import { untrack } from 'svelte'; + import { get } from 'svelte/store'; import type { ConflictEntriesObj } from '$lib/files/conflicts'; const DEFAULT_MODEL = 'gpt-4.1'; @@ -44,7 +43,7 @@ conflictEntries?: ConflictEntriesObj; draggableFiles?: boolean; ancestorMostConflictedCommitId?: string; - onselect?: (change: TreeChange) => void; + onselect?: (change: TreeChange, index: number) => void; allowUnselect?: boolean; showLockedIndicator?: boolean; dataTestId?: string; @@ -76,7 +75,6 @@ const [autoCommit] = actionService.autoCommit; const [branchChanges] = actionService.branchChanges; - let currentDisplayIndex = $state(0); let editPatchModal: EditPatchConfirmModal | undefined = $state(); let selectedFilePath = $state(''); @@ -107,8 +105,6 @@ selectedFilePath = ''; } - const fileChunks: TreeChange[][] = $derived(chunk(changes, 100)); - const visibleFiles: TreeChange[] = $derived(fileChunks.slice(0, currentDisplayIndex + 1).flat()); let aiConfigurationValid = $state(false); let active = $state(false); @@ -186,11 +182,6 @@ }); } - function loadMore() { - if (currentDisplayIndex + 1 >= fileChunks.length) return; - currentDisplayIndex += 1; - } - const unrepresentedConflictedEntries = $derived.by(() => { if (!conflictEntries?.entries) return {}; @@ -215,7 +206,7 @@ selectionId, allowUnselect ); - onselect?.(change); + onselect?.(change, idx); return true; } @@ -231,20 +222,28 @@ return; } - return updateSelection({ - allowMultiple: true, - ctrlKey: e.ctrlKey, - metaKey: e.metaKey, - shiftKey: e.shiftKey, - key: e.key, - targetElement: e.currentTarget as HTMLElement, - files: changes, - selectedFileIds, - fileIdSelection: idSelection, - selectionId: selectionId, - preventDefault: () => e.preventDefault() - }); + if ( + updateSelection({ + allowMultiple: true, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + shiftKey: e.shiftKey, + key: e.key, + targetElement: e.currentTarget as HTMLElement, + files: changes, + selectedFileIds, + fileIdSelection: idSelection, + selectionId: selectionId, + preventDefault: () => e.preventDefault() + }) + ) { + const lastAdded = get(idSelection.getById(selectionId).lastAdded); + if (lastAdded) { + onselect?.(change, lastAdded.index); + } + } } + const currentSelection = $derived(idSelection.getById(selectionId)); const lastAdded = $derived(currentSelection.lastAdded); const lastAddedIndex = $derived($lastAdded?.index); @@ -259,7 +258,7 @@ }); -{#snippet fileTemplate(change: TreeChange, idx: number, depth: number = 0)} +{#snippet fileTemplate(change: TreeChange, idx: number, depth: number = 0, isLast: boolean = false)} {@const isExecutable = isExecutableStatus(change.status)} {@const selected = idSelection.has(change.path, selectionId)} {@const locked = showLockedIndicator && isFileLocked(change.path, fileDependencies)} @@ -269,7 +268,6 @@ {@const lockedTargets = showLockedIndicator ? getLockedTargets(change.path, fileDependencies) : []} - {@const isLast = listMode === 'list' && idx === visibleFiles.length - 1} @@ -365,10 +365,12 @@ {/if} - {#if visibleFiles.length > 0} + {#if changes.length > 0} {#if listMode === 'tree'} - + {@const node = abbreviateFolders(changesToFileTree(changes))} {:else} - { - loadMore(); - }} - role="listbox" - > - {#each visibleFiles as change, idx} - {@render fileTemplate(change, idx)} - {/each} - + + {#snippet template(change, context)} + + {@const _selected = idSelection.has(change.path, selectionId)} + {@render fileTemplate(change, context.index, 0, context.last)} + {/snippet} + {/if} {/if}
diff --git a/apps/desktop/src/components/FileListItemWrapper.svelte b/apps/desktop/src/components/FileListItemWrapper.svelte index 9dc5d596811..5ba1444b7e1 100644 --- a/apps/desktop/src/components/FileListItemWrapper.svelte +++ b/apps/desktop/src/components/FileListItemWrapper.svelte @@ -63,6 +63,7 @@ onclick, onkeydown }: Props = $props(); + const idSelection = inject(FILE_SELECTION_MANAGER); const uncommittedService = inject(UNCOMMITTED_SERVICE); const dropzoneRegistry = inject(DROPZONE_REGISTRY); diff --git a/apps/desktop/src/components/ImageDiff.svelte b/apps/desktop/src/components/ImageDiff.svelte index 7299ff90010..1ce723f35fb 100644 --- a/apps/desktop/src/components/ImageDiff.svelte +++ b/apps/desktop/src/components/ImageDiff.svelte @@ -3,6 +3,7 @@ import { FILE_SERVICE } from '$lib/files/fileService'; import { inject } from '@gitbutler/core/context'; import { ImageDiff, EmptyStatePlaceholder } from '@gitbutler/ui'; + import { untrack } from 'svelte'; import type { TreeChange } from '$lib/hunks/change'; type Props = { @@ -164,7 +165,9 @@ $effect(() => { const abortController = new AbortController(); - loadImages(abortController.signal); + untrack(() => { + loadImages(abortController.signal); + }); return () => abortController.abort(); }); diff --git a/apps/desktop/src/components/ChunkyList.svelte b/apps/desktop/src/components/LazyList.svelte similarity index 50% rename from apps/desktop/src/components/ChunkyList.svelte rename to apps/desktop/src/components/LazyList.svelte index 81e5805fa8e..df307dae512 100644 --- a/apps/desktop/src/components/ChunkyList.svelte +++ b/apps/desktop/src/components/LazyList.svelte @@ -1,5 +1,11 @@ @@ -49,8 +42,12 @@ loadMore(); }} > - {#each displayedItems as displayedItem} - {@render item(displayedItem)} + {#each items.slice(0, displayCount) as item, index} + {@render template(item, { + index, + first: index === 0, + last: index === items.length - 1 + })} {/each} {/if} diff --git a/apps/desktop/src/components/MultiDiffView.svelte b/apps/desktop/src/components/MultiDiffView.svelte new file mode 100644 index 00000000000..b8239955f87 --- /dev/null +++ b/apps/desktop/src/components/MultiDiffView.svelte @@ -0,0 +1,166 @@ + + + +
+ {#if onclose} +
+
+ {/if} + + {#if changes && changes.length > 0} + + {#snippet template(change, index)} + {@const diffQuery = diffService.getDiff(projectId, change)} + {@const diffData = diffQuery.response} + {@const isExecutable = isExecutableStatus(change.status)} + {@const patchData = diffData?.type === 'Patch' ? diffData.subject : null} + + {#snippet header()} + + {/snippet} + + {#snippet actions()} +
+ + diff --git a/apps/desktop/src/components/NestedChangedFiles.svelte b/apps/desktop/src/components/NestedChangedFiles.svelte new file mode 100644 index 00000000000..6f028656555 --- /dev/null +++ b/apps/desktop/src/components/NestedChangedFiles.svelte @@ -0,0 +1,141 @@ + + +
+
+
+
+

{title}

+
+ {changes.length} + {#if stats} + + {/if} +
+
+ + +
+ + {#if changes.length === 0 && !hasConflicts} + + {#snippet caption()} + No files changed + {/snippet} + + {:else} + + {/if} +
+
+ + diff --git a/apps/desktop/src/components/PreviewHeader.svelte b/apps/desktop/src/components/PreviewHeader.svelte index 74b733edbee..d9c28ca756f 100644 --- a/apps/desktop/src/components/PreviewHeader.svelte +++ b/apps/desktop/src/components/PreviewHeader.svelte @@ -1,12 +1,19 @@ +{#if sticky && reserveSpaceOnStuck} +
+{/if} +