diff --git a/apps/desktop/src/components/BranchCard.svelte b/apps/desktop/src/components/BranchCard.svelte index 143574c8d74..f5be7219d99 100644 --- a/apps/desktop/src/components/BranchCard.svelte +++ b/apps/desktop/src/components/BranchCard.svelte @@ -168,7 +168,7 @@ > {#if args.type === "stack-branch"} {@const moveHandler = args.stackId - ? new MoveCommitDzHandler(stackService, args.stackId, projectId, uiState) + ? new MoveCommitDzHandler(args.stackId, projectId) : undefined} {#if !args.prNumber && args.stackId} diff --git a/apps/desktop/src/components/BranchCommitList.svelte b/apps/desktop/src/components/BranchCommitList.svelte index 2f4dfaa8e1a..916571ce974 100644 --- a/apps/desktop/src/components/BranchCommitList.svelte +++ b/apps/desktop/src/components/BranchCommitList.svelte @@ -32,9 +32,11 @@ import { HOOKS_SERVICE } from "$lib/hooks/hooksService"; import { IRC_API_SERVICE } from "$lib/irc/ircApiService"; import { createCommitSelection } from "$lib/selection/key"; + import { getStackContext } from "$lib/stack/stackController.svelte"; import { STACK_SERVICE } from "$lib/stacks/stackService.svelte"; + import { combineResults } from "$lib/state/helpers"; - import { UI_STATE } from "$lib/state/uiState.svelte"; + import { ensureValue } from "$lib/utils/validation"; import { inject } from "@gitbutler/core/context"; import { persisted } from "@gitbutler/shared/persisted"; import { Button, Modal, RadioButton, TestId } from "@gitbutler/ui"; @@ -45,61 +47,36 @@ import type { BranchDetails } from "$lib/stacks/stack"; interface Props { - projectId: string; - stackId?: string; - laneId: string; branchName: string; lastBranch: boolean; branchDetails: BranchDetails; stackingReorderDropzoneManager: ReorderCommitDzFactory; roundedTop?: boolean; - active?: boolean; - visibleRange?: { start: number; end: number }; - - handleUncommit: (commitId: string, branchName: string) => Promise; - startEditingCommitMessage: (branchName: string, commitId: string) => void; - onclick?: () => void; - onFileClick?: (index: number) => void; } - let { - projectId, - stackId, - laneId, - branchName, - branchDetails, - lastBranch, - stackingReorderDropzoneManager, - roundedTop, - active, - visibleRange, - handleUncommit, - startEditingCommitMessage, - onclick, - onFileClick, - }: Props = $props(); + let { branchName, branchDetails, lastBranch, stackingReorderDropzoneManager, roundedTop }: Props = + $props(); + const controller = getStackContext(); const stackService = inject(STACK_SERVICE); - const uiState = inject(UI_STATE); const forge = inject(DEFAULT_FORGE_FACTORY); const hooksService = inject(HOOKS_SERVICE); const ircApiService = inject(IRC_API_SERVICE); const dropzoneRegistry = inject(DROPZONE_REGISTRY); const dragStateService = inject(DRAG_STATE_SERVICE); + // Reactive aliases for template readability (passed to leaf components) + const projectId = $derived(controller.projectId); + const stackId = $derived(controller.stackId); + const commitReactionsQuery = $derived(ircApiService.commitReactions()); const commitReactions = $derived(commitReactionsQuery?.response ?? {}); const [integrateUpstreamCommits, integrating] = stackService.integrateUpstreamCommits; - const projectState = $derived(uiState.project(projectId)); - const exclusiveAction = $derived(projectState.exclusiveAction.current); + const exclusiveAction = $derived(controller.exclusiveAction); const commitAction = $derived(exclusiveAction?.type === "commit" ? exclusiveAction : undefined); - const isCommitting = $derived( - exclusiveAction?.type === "commit" && exclusiveAction.stackId === stackId, - ); - const laneState = $derived(uiState.lane(laneId)); - const selection = $derived(laneState.selection); + const selection = $derived(controller.selection); const runHooks = $derived(projectRunCommitHooks(projectId)); const selectedBranchName = $derived(selection.current?.branchName); @@ -113,14 +90,33 @@ let integrationModal = $state(); async function handleCommitClick(commitId: string, upstream: boolean) { - const currentSelection = laneState.selection.current; + const currentSelection = controller.selection.current; // Toggle: if this exact commit is already selected, clear the selection if (currentSelection?.commitId === commitId && currentSelection?.branchName === branchName) { - laneState.selection.set(undefined); + controller.selection.set(undefined); } else { - laneState.selection.set({ branchName, commitId, upstream, previewOpen: true }); + controller.selection.set({ branchName, commitId, upstream, previewOpen: true }); } - onclick?.(); + controller.clearWorktreeSelection(); + } + + async function handleUncommit(commitId: string) { + await stackService.uncommit({ + projectId, + stackId: ensureValue(stackId), + branchName, + commitId, + }); + } + + function startEditingCommitMessage(commitId: string) { + controller.selection.set({ branchName, commitId, previewOpen: true }); + controller.projectState.exclusiveAction.set({ + type: "edit-commit-message", + stackId, + branchName, + commitId, + }); } function kickOffIntegration() { @@ -215,7 +211,7 @@ {/snippet} {#snippet commitReorderDz(dropzone: ReorderCommitDzHandler)} - {#if !isCommitting} + {#if !controller.isCommitting} {#snippet overlay({ hovered, activated })} @@ -247,7 +243,7 @@ {@const lastCommit = i === upstreamOnlyCommits.length - 1} {@const selected = commit.id === selectedCommitId && branchName === selectedBranchName} {@const commitId = commit.id} - {#if !isCommitting} + {#if !controller.isCommitting} handleCommitClick(commit.id, true)} disableCommitActions={false} @@ -283,7 +279,7 @@ {#snippet template(commit, { first, last })} {@const commitId = commit.id} {@const selected = commit.id === selectedCommitId && branchName === selectedBranchName} - {#if isCommitting} + {#if controller.isCommitting} { - projectState.exclusiveAction.set({ + controller.projectState.exclusiveAction.set({ type: "commit", stackId, branchName, @@ -311,17 +307,14 @@ {@const { amendHandler, squashHandler, hunkHandler } = createCommitDropHandlers({ projectId, stackId, - stackService, - hooksService, - uiState, commit: dzCommit, runHooks: $runHooks, okWithForce: true, onCommitIdChange: (newId) => { - const wasSelected = laneState.selection.current?.commitId === commitId; + const wasSelected = controller.selection.current?.commitId === commitId; if (stackId && wasSelected) { const previewOpen = selection.current?.previewOpen ?? false; - uiState.lane(stackId).selection.set({ branchName, commitId: newId, previewOpen }); + controller.laneState.selection.set({ branchName, commitId: newId, previewOpen }); } }, })} @@ -378,7 +371,7 @@ {lastBranch} {selected} {tooltip} - {active} + active={controller.active} reactions={commitReactions[commit.id]} onclick={() => handleCommitClick(commit.id, false)} disableCommitActions={false} @@ -391,8 +384,8 @@ commitMessage: commit.message, commitStatus: commit.state.type, commitUrl: forge.current.commitUrl(commitId), - onUncommitClick: () => handleUncommit(commit.id, branchName), - onEditMessageClick: () => startEditingCommitMessage(branchName, commit.id), + onUncommitClick: () => handleUncommit(commit.id), + onEditMessageClick: () => startEditingCommitMessage(commit.id), }} { // Ensure the commit is selected so the preview shows it - const currentSelection = laneState.selection.current; + const currentSelection = controller.selection.current; if ( currentSelection?.commitId !== commitId || currentSelection?.branchName !== branchName ) { - laneState.selection.set({ + controller.selection.set({ branchName, commitId, upstream: false, previewOpen: true, }); } - onFileClick?.(index); + controller.jumpToIndex(index); }} /> {/snippet} @@ -456,7 +449,7 @@ {@render commitReorderDz( stackingReorderDropzoneManager.belowCommit(branchName, commit.id), )} - {#if isCommitting && last} + {#if controller.isCommitting && last} { - projectState.exclusiveAction.set({ + controller.projectState.exclusiveAction.set({ type: "commit", stackId, branchName, diff --git a/apps/desktop/src/components/BranchInsertion.svelte b/apps/desktop/src/components/BranchInsertion.svelte index 5cb7b867fc0..ad8177f96a4 100644 --- a/apps/desktop/src/components/BranchInsertion.svelte +++ b/apps/desktop/src/components/BranchInsertion.svelte @@ -4,7 +4,6 @@ import Dropzone from "$components/Dropzone.svelte"; import { MoveBranchDzHandler } from "$lib/branches/dropHandler"; import type { ForgePrService } from "$lib/forge/interface/forgePrService"; - import type { StackService } from "$lib/stacks/stackService.svelte"; interface Props { projectId: string; @@ -13,7 +12,6 @@ lineColor: string; isCommitting: boolean; baseBranchName: string | undefined; - stackService: StackService; prService: ForgePrService | undefined; isFirst?: boolean; } @@ -25,7 +23,6 @@ lineColor, isCommitting, baseBranchName, - stackService, prService, isFirst = false, }: Props = $props(); @@ -33,7 +30,6 @@ {#if !isCommitting && baseBranchName} {@const moveBranchHandler = new MoveBranchDzHandler( - stackService, prService, projectId, stackId, diff --git a/apps/desktop/src/components/BranchList.svelte b/apps/desktop/src/components/BranchList.svelte index 6497a344083..553ccf453e2 100644 --- a/apps/desktop/src/components/BranchList.svelte +++ b/apps/desktop/src/components/BranchList.svelte @@ -7,7 +7,6 @@ import BranchHeaderContextMenu from "$components/BranchHeaderContextMenu.svelte"; import BranchInsertion from "$components/BranchInsertion.svelte"; import CodegenRow from "$components/CodegenRow.svelte"; - import ConflictResolutionConfirmModal from "$components/ConflictResolutionConfirmModal.svelte"; import NestedChangedFiles from "$components/NestedChangedFiles.svelte"; import PushButton from "$components/PushButton.svelte"; import ReduxResult from "$components/ReduxResult.svelte"; @@ -19,110 +18,43 @@ import { currentStatus } from "$lib/codegen/messages"; import { projectDisableCodegen } from "$lib/config/config"; import { REORDER_DROPZONE_FACTORY } from "$lib/dragging/stackingReorderDropzoneManager"; - import { editPatch } from "$lib/editMode/editPatchUtils"; import { DEFAULT_FORGE_FACTORY } from "$lib/forge/forgeFactory.svelte"; - import { MODE_SERVICE } from "$lib/mode/modeService"; import { createBranchSelection } from "$lib/selection/key"; - import { UNCOMMITTED_SERVICE } from "$lib/selection/uncommittedService.svelte"; + import { getStackContext } from "$lib/stack/stackController.svelte"; import { type BranchDetails } from "$lib/stacks/stack"; import { STACK_SERVICE } from "$lib/stacks/stackService.svelte"; import { combineResults } from "$lib/state/helpers"; - import { UI_STATE } from "$lib/state/uiState.svelte"; import { URL_SERVICE } from "$lib/utils/url"; import { ensureValue } from "$lib/utils/validation"; import { inject } from "@gitbutler/core/context"; - import { Button, Modal, TestId } from "@gitbutler/ui"; + import { Button, TestId } from "@gitbutler/ui"; import { QueryStatus } from "@reduxjs/toolkit/query"; import { tick } from "svelte"; - import type { CommitStatusType } from "$lib/commits/commit"; type Props = { - projectId: string; - stackId?: string; - laneId: string; branches: BranchDetails[]; - active: boolean; - onclick?: () => void; - onFileClick?: (index: number) => void; - visibleRange?: { start: number; end: number }; }; - const { - projectId, - branches, - stackId, - laneId, - active, - visibleRange, - onclick, - onFileClick, - }: Props = $props(); + const { branches }: Props = $props(); + + const controller = getStackContext(); const stackService = inject(STACK_SERVICE); - const uiState = inject(UI_STATE); - const modeService = inject(MODE_SERVICE); const forge = inject(DEFAULT_FORGE_FACTORY); const urlService = inject(URL_SERVICE); const baseBranchService = inject(BASE_BRANCH_SERVICE); const claudeCodeService = inject(CLAUDE_CODE_SERVICE); - const uncommittedService = inject(UNCOMMITTED_SERVICE); - - // Component is read-only when stackId is undefined - const isReadOnly = $derived(!stackId); + // Reactive aliases for template readability (passed to leaf components) + const projectId = $derived(controller.projectId); + const stackId = $derived(controller.stackId); + const laneId = $derived(controller.laneId); let addDependentBranchModalContext = $state(); let addDependentBranchModal = $state(); - const projectState = $derived(uiState.project(projectId)); - const exclusiveAction = $derived(projectState.exclusiveAction.current); - const isCommitting = $derived( - exclusiveAction?.type === "commit" && exclusiveAction?.stackId === stackId, - ); - const laneState = $derived(uiState.lane(laneId)); - const selection = $derived(laneState.selection); - const selectedCommitId = $derived(selection.current?.commitId); + const selection = $derived(controller.selection); + const selectedCommitId = $derived(controller.commitId); const codegenDisabled = $derived(projectDisableCodegen(projectId)); - let conflictResolutionConfirmationModal = - $state>(); - - async function handleUncommit(commitId: string, branchName: string) { - await stackService.uncommit({ - projectId, - stackId: ensureValue(stackId), - branchName, - commitId: commitId, - }); - } - - function startEditingCommitMessage(branchName: string, commitId: string) { - laneState.selection.set({ branchName, commitId, previewOpen: true }); - projectState.exclusiveAction.set({ - type: "edit-commit-message", - stackId, - branchName, - commitId, - }); - } - - async function handleEditPatch(args: { - commitId: string; - type: CommitStatusType; - hasConflicts: boolean; - isAncestorMostConflicted: boolean; - }) { - if (isReadOnly) return; - if (args.type === "LocalAndRemote" && args.hasConflicts && !args.isAncestorMostConflicted) { - conflictResolutionConfirmationModal?.show(); - return; - } - await editPatch({ - modeService, - commitId: args.commitId, - stackId: ensureValue(stackId), - projectId, - }); - } - const selectedCommit = $derived( selectedCommitId ? stackService.commitDetails(projectId, selectedCommitId) : undefined, ); @@ -205,22 +137,15 @@ {@const codegenQuery = stackId ? claudeCodeService.messages({ projectId, stackId }) : undefined} - {@const startCommittingDz = new StartCommitDzHandler( - uiState, - uncommittedService, - projectId, - stackId, - branchName, - )} + {@const startCommittingDz = new StartCommitDzHandler(projectId, stackId, branchName)} {#if stackId} @@ -233,7 +158,7 @@ {branchName} {lineColor} {first} - {isCommitting} + isCommitting={controller.isCommitting} {iconName} {selected} {isNewBranch} @@ -254,18 +179,18 @@ trackingBranch={branch.remoteTrackingBranch ?? undefined} readonly={!!branch.remoteTrackingBranch} onclick={() => { - const currentSelection = uiState.lane(laneId).selection.current; + const currentSelection = controller.selection.current; // Toggle: if this branch is already selected, clear the selection if ( currentSelection?.branchName === branchName && !currentSelection.codegen && !currentSelection?.commitId ) { - uiState.lane(laneId).selection.set(undefined); + controller.selection.set(undefined); } else { - uiState.lane(laneId).selection.set({ branchName, previewOpen: true }); + controller.selection.set({ branchName, previewOpen: true }); } - onclick?.(); + controller.clearWorktreeSelection(); }} > {#snippet buttons()} @@ -274,7 +199,7 @@ icon="stack-plus" size="tag" kind="outline" - tooltip={isReadOnly ? "Read-only mode" : "Create new branch"} + tooltip={controller.isReadOnly ? "Read-only mode" : "Create new branch"} onclick={async () => { addDependentBranchModalContext = { projectId, @@ -284,7 +209,7 @@ await tick(); addDependentBranchModal?.show(); }} - disabled={isReadOnly} + disabled={controller.isReadOnly} /> {/if} @@ -296,14 +221,14 @@ shrinkable onclick={(e) => { e.stopPropagation(); - projectState.exclusiveAction.set({ + controller.projectState.exclusiveAction.set({ type: "create-pr", stackId, branchName, }); }} testId={TestId.CreateReviewButton} - disabled={!!projectState.exclusiveAction.current} + disabled={!!controller.exclusiveAction} icon="pr-plus" > {`Create ${forge.current.name === "gitlab" ? "MR" : "PR"}`} @@ -342,7 +267,7 @@ tooltip="New Codegen Session" onclick={async () => { if (!stackId) return; - laneState?.selection.set({ branchName, codegen: true, previewOpen: true }); + controller.selection.set({ branchName, codegen: true, previewOpen: true }); focusClaudeInput(stackId); }} /> @@ -379,7 +304,7 @@ {stackId} {status} selected={codegenSelected} - {onclick} + onclick={() => controller.clearWorktreeSelection()} /> {/if} {/if} @@ -416,17 +341,17 @@ changes={result.changes} stats={result.stats} allowUnselect={false} - {visibleRange} + visibleRange={controller.visibleRange} onFileClick={(index) => { // Ensure the branch is selected so the preview shows it - const currentSelection = laneState.selection.current; + const currentSelection = controller.selection.current; if ( currentSelection?.branchName !== branchName || currentSelection?.commitId !== undefined ) { - laneState.selection.set({ branchName, previewOpen: true }); + controller.selection.set({ branchName, previewOpen: true }); } - onFileClick?.(index); + controller.jumpToIndex(index); }} /> {/snippet} @@ -437,9 +362,6 @@ {#snippet branchContent()} 0} - {active} - {visibleRange} - {handleUncommit} - {startEditingCommitMessage} - {onclick} - {onFileClick} /> {/snippet} @@ -461,33 +377,6 @@ {/each} - void; - }} - onSubmit={async (close, item) => { - await handleEditPatch(item); - close(); - }} -> -
-

It's generally better to start resolving conflicts from the bottom up.

-
-

Are you sure you want to resolve conflicts for this commit?

-
- {#snippet controls(close)} - - - {/snippet} -
- {#if addDependentBranchModalContext} - - -{#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)} - {@const lockedCommitIds = showLockedIndicator - ? getLockedCommitIds(change.path, fileDependencies) - : []} - {@const lockedTargets = showLockedIndicator - ? getLockedTargets(change.path, fileDependencies) - : []} - = visibleRange.start && - idx < visibleRange.end} - draggable={draggableFiles} - executable={isExecutable} - showCheckbox={showCheckboxes} - ircWorkingUsers={ircWorkingUsersByPath?.get(change.path)} - focusableOpts={{ - onKeydown: (e) => handleKeyDown(change, idx, e), - focusable: true, - }} - onclick={(e) => { - e.stopPropagation(); - selectFilesInList(e, change, changes, idSelection, true, idx, selectionId, allowUnselect); - if (idSelection.has(change.path, selectionId)) { - onFileClick?.(idx); - } - }} - {conflictEntries} - /> -{/snippet} - -
(active = value), - }} -> - - {#if Object.keys(unrepresentedConflictedEntries).length > 0} - {@const entries = Object.entries(unrepresentedConflictedEntries)} -
- {#each entries as [path, kind], i} - { - e.stopPropagation(); - showEditPatchConfirmation(path); - }} - /> - {/each} - - {#if ancestorMostConflictedCommitId} -
-

- If the branch has multiple conflicted commits, GitButler opens the earliest one first, - since later commits depend on it. -

- - editPatch({ - modeService, - commitId: ancestorMostConflictedCommitId!, - stackId: stackId!, - projectId, - })} - > - Resolve conflicts - -
- {/if} -
- {/if} - - - {#if changes.length > 0} - {#if listMode === "tree"} - - {@const node = abbreviateFolders(changesToFileTree(changes))} - - {:else} - - {#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/FileListConflicts.svelte b/apps/desktop/src/components/FileListConflicts.svelte new file mode 100644 index 00000000000..8f3ef70060a --- /dev/null +++ b/apps/desktop/src/components/FileListConflicts.svelte @@ -0,0 +1,144 @@ + + + +{#if Object.keys(unrepresentedConflictedEntries).length > 0} + {@const entries = Object.entries(unrepresentedConflictedEntries)} +
+ {#each entries as [path, kind], i} + { + e.stopPropagation(); + showEditPatchConfirmation(path); + }} + /> + {/each} + + {#if ancestorMostConflictedCommitId} +
+

+ If the branch has multiple conflicted commits, GitButler opens the earliest one first, + since later commits depend on it. +

+ + editPatch({ + modeService, + commitId: ancestorMostConflictedCommitId!, + stackId: stackId!, + projectId, + })} + > + Resolve conflicts + +
+ {/if} +
+{/if} + + + + diff --git a/apps/desktop/src/components/FileListItems.svelte b/apps/desktop/src/components/FileListItems.svelte new file mode 100644 index 00000000000..301bf1aeb51 --- /dev/null +++ b/apps/desktop/src/components/FileListItems.svelte @@ -0,0 +1,205 @@ + + + +{#snippet fileTemplate(change: TreeChange, idx: number, depth: number = 0, isLast: boolean = false)} + {@const isExecutable = isExecutableStatus(change.status)} + {@const selected = controller.isSelected(change.path)} + {@const locked = showLockedIndicator && isFileLocked(change.path, fileDependencies)} + {@const lockedCommitIds = showLockedIndicator + ? getLockedCommitIds(change.path, fileDependencies) + : []} + {@const lockedTargets = showLockedIndicator + ? getLockedTargets(change.path, fileDependencies) + : []} + = visibleRange.start && + idx < visibleRange.end} + {draggable} + executable={isExecutable} + showCheckbox={showCheckboxes} + ircWorkingUsers={ircWorkingUsersByPath?.get(change.path)} + focusableOpts={{ + onKeydown: (e) => { + // 1. Activation keys (Enter/Space/l) + if (controller.handleActivation(change, idx, e)) { + onselect?.(change, idx); + return; + } + // 2. Extra handlers (e.g. AI shortcuts) + if (extraKeyHandlers) { + for (const handler of extraKeyHandlers) { + if (handler(change, idx, e)) return; + } + } + // 3. Arrow/vim navigation + const navigatedIndex = controller.handleNavigation(e); + if (navigatedIndex !== undefined) { + const navigatedChange = controller.changes[navigatedIndex]; + if (navigatedChange) onselect?.(navigatedChange, navigatedIndex); + } + }, + focusable: true, + }} + onclick={(e) => { + e.stopPropagation(); + controller.select(e, change, idx); + onselect?.(change, idx); + }} + {conflictEntries} + /> +{/snippet} + +
(controller.active = value), + }} +> + {#if controller.changes.length > 0} + {#if mode === "tree"} + {@const node = abbreviateFolders(changesToFileTree(controller.changes))} + + {:else} + + {#snippet template(change, context)} + + {@const _selected = controller.isSelected(change.path)} + {@render fileTemplate(change, context.index, 0, context.last)} + {/snippet} + + {/if} + {/if} +
+ + diff --git a/apps/desktop/src/components/FileListProvider.svelte b/apps/desktop/src/components/FileListProvider.svelte new file mode 100644 index 00000000000..fba359ba674 --- /dev/null +++ b/apps/desktop/src/components/FileListProvider.svelte @@ -0,0 +1,39 @@ + + + +{@render children()} diff --git a/apps/desktop/src/components/IrcCommit.svelte b/apps/desktop/src/components/IrcCommit.svelte index 5fcc4cf1036..6c914c422ea 100644 --- a/apps/desktop/src/components/IrcCommit.svelte +++ b/apps/desktop/src/components/IrcCommit.svelte @@ -1,6 +1,7 @@ + + mcpConfigModal?.open()} + {hasRulesToClear} + projectRegistered={claudeConfig.projectRegistered} +/> + + + + {#snippet children(config, { stackId: resolvedStackId })} + {@const resolvedLaneState = resolvedStackId ? uiState.lane(resolvedStackId) : undefined} + { + const disabledServers = resolvedLaneState?.disabledMcpServers.current; + if (disabledServers) { + if (disabledServers.includes(server)) { + resolvedLaneState?.disabledMcpServers.set(disabledServers.filter((s) => s !== server)); + } else { + resolvedLaneState?.disabledMcpServers.set([...disabledServers, server]); + } + } + }} + /> + {/snippet} + diff --git a/apps/desktop/src/components/StackDetails.svelte b/apps/desktop/src/components/StackDetails.svelte new file mode 100644 index 00000000000..4d00d29da0a --- /dev/null +++ b/apps/desktop/src/components/StackDetails.svelte @@ -0,0 +1,324 @@ + + + +
+ {#if stackId && selection?.irc && ircChannel} + + {:else if stackId && selection?.branchName && selection?.codegen} + controller.closePreview()} /> + {:else} + {@const commit = commitQuery?.response} + {@const dzCommit: DzCommitData | undefined = commit + ? { + id: commit.id, + isRemote: isUpstreamCommit(commit), + isIntegrated: + isLocalAndRemoteCommit(commit) && commit.state.type === "Integrated", + hasConflicts: isLocalAndRemoteCommit(commit) && commit.hasConflicts, + } + : undefined} + {@const { amendHandler, squashHandler, hunkHandler } = + controller.isCommitView && dzCommit + ? createCommitDropHandlers({ + projectId: controller.projectId, + stackId: controller.stackId, + commit: dzCommit, + runHooks: $runHooks, + okWithForce: true, + onCommitIdChange: (newId) => { + if (stackId && branchName && selection) { + const previewOpen = selection.previewOpen ?? true; + controller.laneState.selection.set({ + branchName, + commitId: newId, + previewOpen, + }); + } + }, + }) + : { amendHandler: undefined, squashHandler: undefined, hunkHandler: undefined }} + {#if branchName && commitId} + + {#snippet overlay({ hovered, activated, handler })} + {@const label = + handler instanceof AmendCommitWithChangeDzHandler || + handler instanceof AmendCommitWithHunkDzHandler + ? "Amend" + : "Squash"} + + {/snippet} +
+ controller.closePreview()} + onpopout={() => controller.openFloatingDiff()} + /> + {#if commitFiles} + {@const commitResult = commitFiles?.result} + {#if commitResult} + + {#snippet children(commit)} + + {/snippet} + + {/if} + {/if} +
+
+ {:else if branchName} + {@const changesQuery = stackService.branchChanges({ + projectId: controller.projectId, + stackId: controller.stackId, + branch: branchName, + })} +
+ controller.closePreview()} + rounded + onpopout={() => controller.openFloatingDiff()} + /> + + {#snippet children(result)} + + {/snippet} + +
+ {:else if focusedFileStore} + controller.closePreview()} + startIndex={focusedFileStore ? get(focusedFileStore)?.index : undefined} + {onVisibleChange} + /> + {/if} + {/if} +
+ + +{#if detailsEl} + +{/if} + + diff --git a/apps/desktop/src/components/StackPanel.svelte b/apps/desktop/src/components/StackPanel.svelte new file mode 100644 index 00000000000..165a6ef03bc --- /dev/null +++ b/apps/desktop/src/components/StackPanel.svelte @@ -0,0 +1,283 @@ + + + +
+ + +
+
+ { + dropzoneActivated = activated; + }} + onDropzoneHovered={(hovered) => { + dropzoneHovered = hovered; + }} + onFileClick={(index) => { + controller.selection.set(undefined); + controller.jumpToIndex(index); + }} + > + {#snippet emptyPlaceholder()} + {#if !controller.isCommitting} +
+

+ Drop files to stage or commit directly +

+
+ {/if} + {/snippet} +
+
+ + {#if startCommitVisible.current || controller.isCommitting} + {#if !controller.isCommitting} +
+ +
+ {:else if controller.isCommitting} + + {/if} + {/if} +
+ + {#if ircEnabled && topBranchName} + + {/if} + + +
+ + diff --git a/apps/desktop/src/components/StackView.svelte b/apps/desktop/src/components/StackView.svelte index 19803d262b3..51c8a74e327 100644 --- a/apps/desktop/src/components/StackView.svelte +++ b/apps/desktop/src/components/StackView.svelte @@ -1,63 +1,25 @@ - - {#snippet children(claudeConfig, { stackId })} - {@const laneState = stackId ? uiState.lane(stackId) : undefined} - { - const disabledServers = laneState?.disabledMcpServers.current; - if (disabledServers) { - if (disabledServers.includes(server)) { - laneState?.disabledMcpServers.set(disabledServers.filter((s) => s !== server)); - } else { - laneState?.disabledMcpServers.set([...disabledServers, server]); - } - } - }} - /> - {/snippet} - - diff --git a/apps/desktop/src/components/WorktreeChanges.svelte b/apps/desktop/src/components/WorktreeChanges.svelte index c8022c01ac8..ae5f542f849 100644 --- a/apps/desktop/src/components/WorktreeChanges.svelte +++ b/apps/desktop/src/components/WorktreeChanges.svelte @@ -2,18 +2,23 @@ import CardOverlay from "$components/CardOverlay.svelte"; import ScrollableContainer from "$components/ConfigurableScrollableContainer.svelte"; import Dropzone from "$components/Dropzone.svelte"; - import FileList from "$components/FileList.svelte"; + import FileListItems from "$components/FileListItems.svelte"; import FileListMode from "$components/FileListMode.svelte"; + import FileListProvider from "$components/FileListProvider.svelte"; import WorktreeChangesSelectAll from "$components/WorktreeChangesSelectAll.svelte"; + import { ACTION_SERVICE } from "$lib/actions/actionService.svelte"; + import { AI_SERVICE } from "$lib/ai/service"; import { UncommitDzHandler } from "$lib/commits/dropHandler"; + import { projectAiGenEnabled } from "$lib/config/config"; import { DIFF_SERVICE } from "$lib/hunks/diffService.svelte"; import { AssignmentDropHandler } from "$lib/hunks/dropHandler"; import { IRC_API_SERVICE } from "$lib/irc/ircApiService"; import { WORKING_FILES_BROADCAST } from "$lib/irc/workingFilesBroadcast.svelte"; import { FILE_SELECTION_MANAGER } from "$lib/selection/fileSelectionManager.svelte"; + import type { FileListKeyHandler } from "$lib/selection/fileListController.svelte"; import { createWorktreeSelection } from "$lib/selection/key"; import { UNCOMMITTED_SERVICE } from "$lib/selection/uncommittedService.svelte"; - import { STACK_SERVICE } from "$lib/stacks/stackService.svelte"; + import { showToast } from "$lib/notifications/toasts"; import { UI_STATE } from "$lib/state/uiState.svelte"; import { inject, injectOptional } from "@gitbutler/core/context"; @@ -22,6 +27,7 @@ import { isDefined } from "@gitbutler/ui/utils/typeguards"; import { type Snippet } from "svelte"; import type { DropzoneHandler } from "$lib/dragging/handler"; + import type { TreeChange } from "$lib/hunks/change"; type Props = { projectId: string; @@ -54,7 +60,6 @@ // Create a unique persist ID based on stackId and mode (both are static props) const persistId = stackId ? `worktree-${mode}-${stackId}` : `worktree-${mode}`; - const stackService = inject(STACK_SERVICE); const diffService = inject(DIFF_SERVICE); const uncommittedService = inject(UNCOMMITTED_SERVICE); const uiState = inject(UI_STATE); @@ -73,9 +78,7 @@ // Create selectionId for this worktree lane const selectionId = $derived(createWorktreeSelection({ stackId })); - const uncommitDzHandler = $derived( - new UncommitDzHandler(projectId, stackService, uiState, stackId), - ); + const uncommitDzHandler = $derived(new UncommitDzHandler(projectId, stackId)); const projectState = $derived(uiState.project(projectId)); const exclusiveAction = $derived(projectState.exclusiveAction.current); @@ -94,6 +97,94 @@ new AssignmentDropHandler(projectId, diffService, uncommittedService, stackId, idSelection), ); + // --- AI actions (Ctrl+Alt+B to branch, Ctrl+Alt+C to auto-commit) --- + const DEFAULT_MODEL = "gpt-4.1"; + const aiService = inject(AI_SERVICE); + const actionService = inject(ACTION_SERVICE); + const [autoCommit] = actionService.autoCommit; + const [branchChanges] = actionService.branchChanges; + + const aiGenEnabled = $derived(projectAiGenEnabled(projectId)); + let aiConfigurationValid = $state(false); + const canUseGBAI = $derived($aiGenEnabled && aiConfigurationValid); + + $effect(() => { + aiService.validateGitButlerAPIConfiguration().then((value) => { + aiConfigurationValid = value; + }); + }); + + function getSelectedTreeChanges(): TreeChange[] | undefined { + const selectedFiles = idSelection.values(selectionId); + if (selectedFiles.length === 0) return; + const paths = new Set(selectedFiles.map((file) => file.path)); + return changes.current.filter((change) => paths.has(change.path)); + } + + /** + * Create a branch and commit from the selected changes. + * + * _Branch [/bræntʃ/]_ is a verb that means to create a new branch and commit from the current changes. + * + * _According to who? Me._ + * + * - Anonymous + */ + async function branchSelection() { + const treeChanges = getSelectedTreeChanges(); + if (!treeChanges || !canUseGBAI) return; + + showToast({ + style: "info", + title: "Creating a branch and committing the changes", + message: "This may take a few seconds.", + }); + + await branchChanges({ + projectId, + changes: treeChanges, + model: DEFAULT_MODEL, + }); + + showToast({ + style: "success", + title: "And... done!", + message: `Now, you're free to continue`, + }); + } + + async function autoCommitSelection() { + const treeChanges = getSelectedTreeChanges(); + if (!treeChanges) return; + + await autoCommit({ + projectId, + target: { + type: "treeChanges", + subject: { + changes: treeChanges, + assigned_stack_id: stackId ?? null, + }, + }, + useAi: $aiGenEnabled, + }); + } + + const aiKeyHandlers: FileListKeyHandler[] = [ + (_change, _idx, e) => { + if (e.code === "KeyB" && (e.ctrlKey || e.metaKey) && e.altKey) { + branchSelection(); + e.preventDefault(); + return true; + } + if (e.code === "KeyC" && (e.ctrlKey || e.metaKey) && e.altKey) { + autoCommitSelection(); + e.preventDefault(); + return true; + } + }, + ]; + function getDropzoneLabel(handler: DropzoneHandler | undefined): string { if (handler instanceof UncommitDzHandler) { return "Uncommit"; @@ -106,20 +197,21 @@ {#snippet fileList()} - + + onFileClick(index))} + extraKeyHandlers={aiKeyHandlers} + /> + {/snippet} void; + projectRegistered?: boolean; onclose?: () => void; - onChange: (value: string) => void; - onAbort?: () => Promise; - onSubmit?: (prompt: string) => Promise; - onAnswerQuestion?: (answers: Record) => Promise; - onRetryConfig?: () => Promise; + onMcpSettings?: () => void; }; - const { - projectId, - stackId, - laneId, - branchName, - initialPrompt, - isStackActive, - projectRegistered, - events, - permissionRequests, - sessionId, - hasRulesToClear, - onclose, - onChange, - onAbort, - onSubmit, - onMcpSettings, - onAnswerQuestion, - onRetryConfig, - }: Props = $props(); - - const stableBranchName = $derived(branchName); + const { hasRulesToClear, projectRegistered, onclose, onMcpSettings }: Props = $props(); + + const controller = getStackContext(); + const projectId = $derived(controller.projectId); + const stackId = $derived(controller.stackId); + const laneId = $derived(controller.laneId); + const branchName = $derived(controller.branchName ?? ""); const claudeCodeService = inject(CLAUDE_CODE_SERVICE); const rulesService = inject(RULES_SERVICE); - const uiState = inject(UI_STATE); const urlService = inject(URL_SERVICE); const userSettings = inject(SETTINGS); const settingsService = inject(SETTINGS_SERVICE); + const attachmentService = inject(ATTACHMENT_SERVICE); const claudeSettings = $derived($settingsService?.claude); + // ── Data queries ───────────────────────────────────────────────── + const isStackActiveQuery = $derived(claudeCodeService.isStackActive(projectId, stackId)); + const isStackActive = $derived(isStackActiveQuery?.response || false); + const eventsQuery = $derived(claudeCodeService.messages({ projectId, stackId })); + const events = $derived(eventsQuery.response || []); + const sessionIdQuery = $derived(rulesService.aiSessionId(projectId, stackId)); + const sessionId = $derived(sessionIdQuery.response); + const permissionRequestsQuery = $derived(claudeCodeService.permissionRequests({ projectId })); + const permissionRequests = $derived(permissionRequestsQuery.response || []); + const attachments = $derived(attachmentService.getByBranch(branchName)); + const claudeAvailable = $derived(claudeCodeService.checkAvailable(undefined)); - // const canEnterChat = $derived(claudeAvailable.status === "available" && !!projectRegistered); const canEnterChat = $derived(!!projectRegistered); let clearContextModal = $state(); @@ -176,10 +158,26 @@ urlService.openExternalUrl(editorUri); } - const projectState = uiState.project(projectId); - const selectedThinkingLevel = $derived(projectState.thinkingLevel.current); - const selectedModel = $derived(projectState.selectedModel.current); - const selectedPermissionMode = $derived(uiState.lane(laneId).permissionMode.current); + const selectedThinkingLevel = $derived(controller.projectState.thinkingLevel.current); + const selectedModel = $derived(controller.projectState.selectedModel.current); + const selectedPermissionMode = $derived(controller.laneState.permissionMode.current); + + // ── Message sender ─────────────────────────────────────────────── + const messageSender = $derived( + stackId && branchName + ? new MessageSender({ + projectId: reactive(() => projectId), + selectedBranch: reactive(() => ({ + stackId: stackId!, + head: branchName!, + })), + thinkingLevel: reactive(() => selectedThinkingLevel), + model: reactive(() => selectedModel), + permissionMode: reactive(() => selectedPermissionMode), + }) + : undefined, + ); + const initialPrompt = $derived(messageSender?.prompt); async function onPermissionDecision( id: string, @@ -195,17 +193,17 @@ } function selectModel(model: ModelType) { - projectState.selectedModel.set(model); + controller.projectState.selectedModel.set(model); modelContextMenu?.close(); } function selectThinkingLevel(level: ThinkingLevel) { - projectState.thinkingLevel.set(level); + controller.projectState.thinkingLevel.set(level); thinkingModeContextMenu?.close(); } function selectPermissionMode(mode: PermissionMode) { - uiState.lane(laneId).permissionMode.set(mode); + controller.laneState.permissionMode.set(mode); permissionModeContextMenu?.close(); } @@ -233,17 +231,31 @@ async function insertTemplate(templateContent: string) { const currentPrompt = await inputRef?.getText(); const newPrompt = currentPrompt + (currentPrompt ? "\n\n" : "") + templateContent; - onChange?.(newPrompt); + messageSender?.setPrompt(newPrompt); inputRef?.setText(newPrompt); templateContextMenu?.close(); } - // function getCurrentSessionId(events: ClaudeMessage[]): string | undefined { - // // Get the most recent session ID from the messages - // if (events.length === 0) return undefined; - // const lastEvent = events[events.length - 1]; - // return lastEvent?.sessionId; - // } + // ── Actions ────────────────────────────────────────────────────── + async function onAbort() { + if (stackId) { + await claudeCodeService.cancelSession({ projectId, stackId }); + } + } + + async function sendMessage(prompt: string) { + await messageSender?.sendMessage(prompt, attachments); + attachmentService.clearByBranch(branchName); + } + + async function handleAnswerQuestion(answers: Record) { + if (!stackId) return; + await claudeCodeService.answerAskUserQuestion({ projectId, stackId, answers }); + } + + async function retryConfig() { + await claudeCodeService.fetchClaudeConfig({ projectId }, { forceRefetch: true }); + } function clearContextAndRules() { clearContextModal?.show(); @@ -555,22 +567,17 @@ {#if formattedMessages.length > 0} { - uiState.global.modal.set({ - type: "project-settings", - projectId, - selectedId: "agent", - }); + controller.openProjectSettingsModal("agent"); }} /> {/if} {:else if !projectRegistered} {#if formattedMessages.length > 0} - + {/if} {:else} {@const status = currentStatus(events, isStackActive)} - {@const laneState = uiState.lane(laneId)} - {@const addedDirs = laneState.addedDirs.current} + {@const addedDirs = controller.laneState.addedDirs.current}
{#if pendingAskUserQuestion} @@ -578,21 +585,21 @@ questions={pendingAskUserQuestion.questions} answered={pendingAskUserQuestion.answered} onSubmitAnswers={async (answers) => { - await onAnswerQuestion?.(answers); + await handleAnswerQuestion(answers); }} onCancel={async () => { dismissedAskUserQuestions = { ...dismissedAskUserQuestions, [pendingAskUserQuestion.toolUseId]: true, }; - await onAbort?.(); + await onAbort(); }} /> {:else} { - laneState.addedDirs.remove(dir); + controller.laneState.addedDirs.remove(dir); }} /> @@ -600,13 +607,13 @@ bind:this={inputRef} {projectId} {stackId} - branchName={stableBranchName} + {branchName} value={initialPrompt || ""} loading={["running", "compacting"].includes(status)} compacting={status === "compacting"} - {onChange} + onChange={(prompt) => messageSender?.setPrompt(prompt)} onSubmit={async (prompt) => { - await onSubmit?.(prompt); + await sendMessage(prompt); setTimeout(() => { virtualList?.scrollToBottom(); }, 100); diff --git a/apps/desktop/src/lib/branches/dropHandler.ts b/apps/desktop/src/lib/branches/dropHandler.ts index 48f94549502..db45845fe07 100644 --- a/apps/desktop/src/lib/branches/dropHandler.ts +++ b/apps/desktop/src/lib/branches/dropHandler.ts @@ -1,10 +1,11 @@ import { FileChangeDropData, FolderChangeDropData, HunkDropDataV3 } from "$lib/dragging/draggables"; import { updateStackPrs } from "$lib/forge/shared/prFooter"; +import { UNCOMMITTED_SERVICE } from "$lib/selection/uncommittedService.svelte"; +import { STACK_SERVICE } from "$lib/stacks/stackService.svelte"; +import { UI_STATE } from "$lib/state/uiState.svelte"; +import { inject } from "@gitbutler/core/context"; import type { DropzoneHandler } from "$lib/dragging/handler"; import type { ForgePrService } from "$lib/forge/interface/forgePrService"; -import type { UncommittedService } from "$lib/selection/uncommittedService.svelte"; -import type { StackService } from "$lib/stacks/stackService.svelte"; -import type { UiState } from "$lib/state/uiState.svelte"; export class BranchDropData { constructor( @@ -23,8 +24,9 @@ export class BranchDropData { } export class MoveBranchDzHandler implements DropzoneHandler { + private readonly stackService = inject(STACK_SERVICE); + constructor( - private readonly stackService: StackService, private readonly prService: ForgePrService | undefined, private readonly projectId: string, private readonly stackId: string, @@ -67,9 +69,10 @@ export class MoveBranchDzHandler implements DropzoneHandler { } export class StartCommitDzHandler implements DropzoneHandler { + private readonly uiState = inject(UI_STATE); + private readonly uncommittedService = inject(UNCOMMITTED_SERVICE); + constructor( - private readonly uiState: UiState, - private readonly uncommittedService: UncommittedService, private readonly projectId: string, private readonly stackId: string | undefined, private readonly branchName: string, diff --git a/apps/desktop/src/lib/commits/dropHandler.ts b/apps/desktop/src/lib/commits/dropHandler.ts index 63548d077ae..c44bf1605c6 100644 --- a/apps/desktop/src/lib/commits/dropHandler.ts +++ b/apps/desktop/src/lib/commits/dropHandler.ts @@ -9,12 +9,13 @@ import { HunkDropDataV3, type ChangeDropData, } from "$lib/dragging/draggables"; -import { type HooksService } from "$lib/hooks/hooksService"; +import { HOOKS_SERVICE } from "$lib/hooks/hooksService"; import { showToast } from "$lib/notifications/toasts"; +import { STACK_SERVICE } from "$lib/stacks/stackService.svelte"; +import { UI_STATE, type UiState } from "$lib/state/uiState.svelte"; +import { inject } from "@gitbutler/core/context"; import { untrack } from "svelte"; import type { DropzoneHandler } from "$lib/dragging/handler"; -import type { StackService } from "$lib/stacks/stackService.svelte"; -import type { UiState } from "$lib/state/uiState.svelte"; /** Details about a commit belonging to a drop zone. */ export type DzCommitData = { @@ -36,11 +37,12 @@ export class CommitDropData { /** Handler that can move commits between stacks. */ export class MoveCommitDzHandler implements DropzoneHandler { + private readonly uiState = inject(UI_STATE); + private readonly stackService = inject(STACK_SERVICE); + constructor( - private stackService: StackService, private stackId: string, private projectId: string, - private uiState?: UiState, ) {} accepts(data: unknown): boolean { @@ -62,11 +64,9 @@ export class MoveCommitDzHandler implements DropzoneHandler { ondrop(data: CommitDropData): void { // Clear the selection from the source lane if this commit was selected - if (this.uiState) { - const sourceSelection = untrack(() => this.uiState!.lane(data.stackId).selection.current); - if (sourceSelection?.commitId === data.commit.id) { - this.uiState.lane(data.stackId).selection.set(undefined); - } + const sourceSelection = untrack(() => this.uiState.lane(data.stackId).selection.current); + if (sourceSelection?.commitId === data.commit.id) { + this.uiState.lane(data.stackId).selection.set(undefined); } this.stackService @@ -84,15 +84,16 @@ export class MoveCommitDzHandler implements DropzoneHandler { * Handler that will be able to amend a commit using `TreeChange`. */ export class AmendCommitWithChangeDzHandler implements DropzoneHandler { + private readonly uiState = inject(UI_STATE); + private readonly stackService = inject(STACK_SERVICE); + private readonly hooksService = inject(HOOKS_SERVICE); + constructor( private projectId: string, - private readonly stackService: StackService, - private readonly hooksService: HooksService, private stackId: string, private runHooks: boolean, private commit: DzCommitData, private onresult: (result: string) => void, - private readonly uiState: UiState, ) {} accepts(data: unknown): boolean { if (!(data instanceof FileChangeDropData || data instanceof FolderChangeDropData)) return false; @@ -156,10 +157,11 @@ export class AmendCommitWithChangeDzHandler implements DropzoneHandler { } export class UncommitDzHandler implements DropzoneHandler { + private readonly uiState = inject(UI_STATE); + private readonly stackService = inject(STACK_SERVICE); + constructor( private projectId: string, - private readonly stackService: StackService, - private readonly uiState: UiState, private readonly assignTo?: string, ) {} @@ -249,15 +251,16 @@ export class UncommitDzHandler implements DropzoneHandler { * Handler that is able to amend a commit using `Hunk`. */ export class AmendCommitWithHunkDzHandler implements DropzoneHandler { + private readonly uiState = inject(UI_STATE); + private readonly stackService = inject(STACK_SERVICE); + private readonly hooksService = inject(HOOKS_SERVICE); + constructor( private args: { - stackService: StackService; - hooksService: HooksService; okWithForce: boolean; projectId: string; stackId: string; commit: DzCommitData; - uiState: UiState; runHooks: boolean; }, ) {} @@ -275,7 +278,7 @@ export class AmendCommitWithHunkDzHandler implements DropzoneHandler { } async ondrop(data: HunkDropDataV3): Promise { - const { stackService, projectId, stackId, commit, okWithForce, uiState, runHooks } = this.args; + const { projectId, stackId, commit, okWithForce, runHooks } = this.args; if (!okWithForce && commit.isRemote) return; if (data instanceof HunkDropDataV3) { @@ -287,7 +290,7 @@ export class AmendCommitWithHunkDzHandler implements DropzoneHandler { throw new Error("Can't receive a change without it's source or commit"); } - const { replacedCommits } = await stackService.moveChangesBetweenCommits({ + const { replacedCommits } = await this.stackService.moveChangesBetweenCommits({ projectId, destinationStackId: stackId, destinationCommitId: commit.id, @@ -310,8 +313,8 @@ export class AmendCommitWithHunkDzHandler implements DropzoneHandler { }); // Update the project state to point to the new commit if needed. - updateUiState(uiState, data.stackId, data.commitId, replacedCommits); - updateUiState(uiState, stackId, commit.id, replacedCommits); + updateUiState(this.uiState, data.stackId, data.commitId, replacedCommits); + updateUiState(this.uiState, stackId, commit.id, replacedCommits); return; } @@ -333,12 +336,12 @@ export class AmendCommitWithHunkDzHandler implements DropzoneHandler { if (runHooks) { try { - await this.args.hooksService.runPreCommitHooks(projectId, worktreeChanges); + await this.hooksService.runPreCommitHooks(projectId, worktreeChanges); } catch { return; } } - stackService.amendCommitMutation({ + this.stackService.amendCommitMutation({ projectId, stackId, commitId: commit.id, @@ -346,7 +349,7 @@ export class AmendCommitWithHunkDzHandler implements DropzoneHandler { }); if (runHooks) { try { - await this.args.hooksService.runPostCommitHooks(projectId); + await this.hooksService.runPostCommitHooks(projectId); } catch { return; } @@ -359,9 +362,10 @@ export class AmendCommitWithHunkDzHandler implements DropzoneHandler { * Handler that is able to squash two commits using `DzCommitData`. */ export class SquashCommitDzHandler implements DropzoneHandler { + private readonly stackService = inject(STACK_SERVICE); + constructor( private args: { - stackService: StackService; projectId: string; stackId: string; commit: DzCommitData; @@ -380,9 +384,9 @@ export class SquashCommitDzHandler implements DropzoneHandler { } async ondrop(data: unknown) { - const { stackService, projectId, stackId, commit } = this.args; + const { projectId, stackId, commit } = this.args; if (data instanceof CommitDropData) { - await stackService.squashCommits({ + await this.stackService.squashCommits({ projectId, stackId, sourceCommitIds: [data.commit.id], @@ -412,9 +416,6 @@ function updateUiState( export function createCommitDropHandlers(args: { projectId: string; stackId: string | undefined; - stackService: StackService; - hooksService: HooksService; - uiState: UiState; commit: DzCommitData; runHooks: boolean; onCommitIdChange?: (newCommitId: string) => void; @@ -436,32 +437,25 @@ export function createCommitDropHandlers(args: { const amendHandler = new AmendCommitWithChangeDzHandler( args.projectId, - args.stackService, - args.hooksService, stackId, args.runHooks, commit, (newId) => { onCommitIdChange?.(newId); }, - args.uiState, ); const squashHandler = new SquashCommitDzHandler({ - stackService: args.stackService, projectId: args.projectId, stackId, commit, }); const hunkHandler = new AmendCommitWithHunkDzHandler({ - stackService: args.stackService, - hooksService: args.hooksService, projectId: args.projectId, stackId, commit, okWithForce, - uiState: args.uiState, runHooks: args.runHooks, }); diff --git a/apps/desktop/src/lib/selection/fileListController.svelte.ts b/apps/desktop/src/lib/selection/fileListController.svelte.ts new file mode 100644 index 00000000000..54d554ac552 --- /dev/null +++ b/apps/desktop/src/lib/selection/fileListController.svelte.ts @@ -0,0 +1,157 @@ +/** + * Reactive controller that owns file list selection, keyboard navigation, + * and focus management. + * + * Instantiate in a component's ` + * ``` + */ +import { FILE_SELECTION_MANAGER } from "$lib/selection/fileSelectionManager.svelte"; +import { selectFilesInList, updateSelection } from "$lib/selection/fileSelectionUtils"; +import { type SelectionId } from "$lib/selection/key"; +import { inject } from "@gitbutler/core/context"; +import { FOCUS_MANAGER } from "@gitbutler/ui/focus/focusManager"; +import { getContext, setContext, untrack } from "svelte"; +import { get } from "svelte/store"; +import type { TreeChange } from "$lib/hunks/change"; +import type { FileSelectionManager } from "$lib/selection/fileSelectionManager.svelte"; +import type { SelectedFile } from "$lib/selection/key"; + +const FILE_LIST_CTX = Symbol("FileListController"); + +/** Set the controller into Svelte component context. Called by FileListProvider. */ +export function setFileListContext(controller: FileListController): void { + setContext(FILE_LIST_CTX, controller); +} + +/** Read the controller from Svelte component context. Called by compound children. */ +export function getFileListContext(): FileListController { + const ctx = getContext(FILE_LIST_CTX); + if (!ctx) { + throw new Error("FileListController not found — wrap your component in "); + } + return ctx; +} + +/** + * Extra keyboard handler that callers can inject to extend file list + * keyboard behavior (e.g. AI shortcuts in the worktree context). + * + * Return `true` to indicate the event was handled. + */ +export type FileListKeyHandler = ( + change: TreeChange, + idx: number, + e: KeyboardEvent, +) => boolean | void; + +export class FileListController { + private idSelection: FileSelectionManager; + private focusManager; + private getChanges: () => TreeChange[]; + private getSelectionId: () => SelectionId; + private getAllowUnselect: () => boolean; + + active = $state(false); + readonly selectedPaths = $derived(new Set(this.selectedFileIds.map((f) => f.path))); + readonly hasSelectionInList = $derived( + this.changes.some((change) => this.selectedPaths.has(change.path)), + ); + + constructor(params: { + changes: () => TreeChange[]; + selectionId: () => SelectionId; + allowUnselect?: () => boolean; + }) { + this.idSelection = inject(FILE_SELECTION_MANAGER); + this.focusManager = inject(FOCUS_MANAGER); + this.getChanges = params.changes; + this.getSelectionId = params.selectionId; + this.getAllowUnselect = params.allowUnselect ?? (() => true); + + // Sync focus to the last-added selection item. + $effect(() => { + const store = this.idSelection.getById(this.getSelectionId()).lastAdded; + return store.subscribe((value) => { + if (value?.index !== undefined) { + untrack(() => { + if (this.active) { + this.focusManager.focusNthSibling(value.index); + } + }); + } + }); + }); + } + + get selection(): FileSelectionManager { + return this.idSelection; + } + + get selectionId(): SelectionId { + return this.getSelectionId(); + } + + get changes(): TreeChange[] { + return this.getChanges(); + } + + get selectedFileIds(): SelectedFile[] { + return this.idSelection.values(this.selectionId); + } + + isSelected(path: string): boolean { + return this.idSelection.has(path, this.selectionId); + } + + select(e: MouseEvent | KeyboardEvent, change: TreeChange, index: number): void { + selectFilesInList( + e, + change, + this.changes, + this.idSelection, + true, + index, + this.selectionId, + this.getAllowUnselect(), + ); + } + + /** Returns true if the key was an activation key (Enter/Space/l) and select was called. */ + handleActivation(change: TreeChange, idx: number, e: KeyboardEvent): boolean { + if (e.key === "Enter" || e.key === " " || e.key === "l") { + e.stopPropagation(); + this.select(e, change, idx); + return true; + } + return false; + } + + /** Handles arrow/vim navigation. Returns the index of the newly focused item, or undefined. */ + handleNavigation(e: KeyboardEvent): number | undefined { + if ( + updateSelection({ + allowMultiple: true, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + shiftKey: e.shiftKey, + key: e.key, + targetElement: e.currentTarget as HTMLElement, + files: this.changes, + selectedFileIds: this.selectedFileIds, + fileIdSelection: this.idSelection, + selectionId: this.selectionId, + preventDefault: () => e.preventDefault(), + }) + ) { + const lastAdded = get(this.idSelection.getById(this.selectionId).lastAdded); + return lastAdded?.index; + } + return undefined; + } +} diff --git a/apps/desktop/src/lib/stack/stackController.svelte.ts b/apps/desktop/src/lib/stack/stackController.svelte.ts new file mode 100644 index 00000000000..d73c740da4c --- /dev/null +++ b/apps/desktop/src/lib/stack/stackController.svelte.ts @@ -0,0 +1,249 @@ +/** + * Reactive controller that owns shared state for the StackView compound component. + * + * Manages selection coordination, preview state, and cross-panel communication + * (e.g. left panel file click → right panel diff jump). + * + * Instantiate during component init so that `inject()` and `$effect()` bind + * to the component lifecycle. + */ +import { FILE_SELECTION_MANAGER } from "$lib/selection/fileSelectionManager.svelte"; +import { + createBranchSelection, + createCommitSelection, + createWorktreeSelection, + readKey, + type SelectionId, + type SelectedFile, +} from "$lib/selection/key"; +import { UI_STATE } from "$lib/state/uiState.svelte"; +import { inject } from "@gitbutler/core/context"; +import { getContext, setContext } from "svelte"; +import type { FileSelectionManager } from "$lib/selection/fileSelectionManager.svelte"; +import type { ProjectSettingsPageId } from "$lib/settings/projectSettingsPages"; + +const STACK_CTX = Symbol("StackController"); + +/** Set the controller into Svelte component context. */ +export function setStackContext(controller: StackController): void { + setContext(STACK_CTX, controller); +} + +/** Read the controller from Svelte component context. */ +export function getStackContext(): StackController { + const ctx = getContext(STACK_CTX); + if (!ctx) { + throw new Error("StackController not found — wrap your component in a StackView"); + } + return ctx; +} + +export class StackController { + private uiState; + private fileSelection: FileSelectionManager; + private getProjectId: () => string; + private getStackId: () => string | undefined; + private getLaneId: () => string; + + active = $state(false); + visibleRange = $state<{ start: number; end: number } | undefined>(); + + private diffJumpHandler?: (index: number) => void; + private diffPopoutHandler?: () => void; + + private _focusedFile = $state(); + private _stagedFocusedFile = $state(); + + constructor(params: { + projectId: () => string; + stackId: () => string | undefined; + laneId: () => string; + }) { + this.uiState = inject(UI_STATE); + this.fileSelection = inject(FILE_SELECTION_MANAGER); + this.getProjectId = params.projectId; + this.getStackId = params.stackId; + this.getLaneId = params.laneId; + + $effect(() => { + const store = this.focusedFileStore; + if (!store) { + this._focusedFile = undefined; + return; + } + return store.subscribe((value) => { + this._focusedFile = value?.key ? readKey(value.key) : undefined; + }); + }); + + $effect(() => { + const store = this.stagedFocusedFileStore; + if (!store) { + this._stagedFocusedFile = undefined; + return; + } + return store.subscribe((value) => { + this._stagedFocusedFile = value?.key ? readKey(value.key) : undefined; + }); + }); + } + + get projectId(): string { + return this.getProjectId(); + } + + get stackId(): string | undefined { + return this.getStackId(); + } + + get laneId(): string { + return this.getLaneId(); + } + + get isReadOnly(): boolean { + return !this.stackId; + } + + get laneState() { + return this.uiState.lane(this.laneId); + } + + get selection() { + return this.laneState.selection; + } + + get commitId(): string | undefined { + return this.selection.current?.commitId; + } + + get branchName(): string | undefined { + return this.selection.current?.branchName; + } + + get upstream(): boolean | undefined { + return this.selection.current?.upstream; + } + + get previewOpen(): boolean | undefined { + return this.selection.current?.previewOpen; + } + + get isCommitView(): boolean { + return !!(this.branchName && this.commitId); + } + + get projectState() { + return this.uiState.project(this.projectId); + } + + get exclusiveAction() { + return this.projectState.exclusiveAction.current; + } + + get isCommitting(): boolean { + return this.exclusiveAction?.type === "commit" && this.exclusiveAction.stackId === this.stackId; + } + + get dimmed(): boolean { + return ( + this.exclusiveAction?.type === "commit" && this.exclusiveAction?.stackId !== this.stackId + ); + } + + get activeSelectionId(): SelectionId | undefined { + if (this.commitId) { + return createCommitSelection({ commitId: this.commitId, stackId: this.stackId }); + } else if (this.branchName) { + return createBranchSelection({ + stackId: this.stackId, + branchName: this.branchName, + remote: undefined, + }); + } + return createWorktreeSelection({ stackId: this.stackId }); + } + + get focusedFileStore() { + if (this.activeSelectionId) { + return this.fileSelection.getById(this.activeSelectionId).lastAdded; + } + return undefined; + } + + get focusedFile() { + return this._focusedFile; + } + + private get stagedFileGroup() { + return this.fileSelection.getById(createWorktreeSelection({ stackId: this.stackId })); + } + + get stagedFocusedFileStore() { + return this.stagedFileGroup.lastAdded; + } + + get stagedFocusedFile() { + return this._stagedFocusedFile; + } + + get hasPreviewTarget(): boolean { + return !!(this.branchName || this.commitId || this.focusedFile); + } + + get isSelectionPreviewOpen(): boolean { + return this.hasPreviewTarget && !!this.previewOpen; + } + + get hasStagedFileFocused(): boolean { + return !!this.stagedFocusedFile; + } + + get ircPanelOpen(): boolean { + return this.selection.current?.irc === true; + } + + get isDetailsViewOpen(): boolean { + return this.isSelectionPreviewOpen || this.hasStagedFileFocused || this.ircPanelOpen; + } + + closePreview(): void { + if (this.activeSelectionId) { + this.fileSelection.clear(this.activeSelectionId); + } + this.selection.set(undefined); + } + + clearWorktreeSelection(): void { + this.fileSelection.clear({ type: "worktree", stackId: this.stackId }); + } + + openProjectSettingsModal(selectedId?: ProjectSettingsPageId): void { + this.uiState.global.modal.set({ + type: "project-settings", + projectId: this.projectId, + selectedId, + }); + } + + registerDiffView(handlers: { jump: (index: number) => void; popout: () => void }): void { + this.diffJumpHandler = handlers.jump; + this.diffPopoutHandler = handlers.popout; + } + + unregisterDiffView(): void { + this.diffJumpHandler = undefined; + this.diffPopoutHandler = undefined; + } + + jumpToIndex(index: number): void { + this.diffJumpHandler?.(index); + } + + openFloatingDiff(): void { + this.diffPopoutHandler?.(); + } + + setVisibleRange(range: { start: number; end: number } | undefined): void { + this.visibleRange = range; + } +}