From 9e9f5d24afe3551fa2e2f0001665d5c82e10a5c5 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Tue, 17 Mar 2026 16:50:45 +0000 Subject: [PATCH 01/10] Extract FileList into compound components with FileListController Split FileList into a provider and compound children (FileListProvider, FileListItems, FileListConflicts) backed by a FileListController that owns selection, keyboard navigation, and focus state. Consumers compose only the pieces they need and wire callbacks (onselect, extraKeyHandlers) on the child that uses them. --- apps/desktop/src/components/FileList.svelte | 437 ------------------ .../src/components/FileListConflicts.svelte | 144 ++++++ .../src/components/FileListItems.svelte | 205 ++++++++ .../src/components/FileListProvider.svelte | 39 ++ apps/desktop/src/components/IrcCommit.svelte | 21 +- .../src/components/NestedChangedFiles.svelte | 35 +- .../src/components/SnapshotCard.svelte | 42 +- .../src/components/WorktreeChanges.svelte | 126 ++++- .../selection/fileListController.svelte.ts | 162 +++++++ 9 files changed, 713 insertions(+), 498 deletions(-) delete mode 100644 apps/desktop/src/components/FileList.svelte create mode 100644 apps/desktop/src/components/FileListConflicts.svelte create mode 100644 apps/desktop/src/components/FileListItems.svelte create mode 100644 apps/desktop/src/components/FileListProvider.svelte create mode 100644 apps/desktop/src/lib/selection/fileListController.svelte.ts diff --git a/apps/desktop/src/components/FileList.svelte b/apps/desktop/src/components/FileList.svelte deleted file mode 100644 index 57591a16d84..00000000000 --- a/apps/desktop/src/components/FileList.svelte +++ /dev/null @@ -1,437 +0,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)} - {@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 @@ {#snippet fileList()} - + + onFileClick(index))} + extraKeyHandlers={aiKeyHandlers} + /> + {/snippet} ` block so that `inject()` and + * `$effect()` bind to the component lifecycle. + * + * ```svelte + * + * ``` + */ +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); + + 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. + const currentSelection = $derived(this.idSelection.getById(this.getSelectionId())); + const lastAdded = $derived(currentSelection.lastAdded); + const lastAddedIndex = $derived(get(lastAdded)?.index); + + $effect(() => { + if (lastAddedIndex !== undefined) { + untrack(() => { + if (this.active) { + this.focusManager.focusNthSibling(lastAddedIndex); + } + }); + } + }); + } + + 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); + } + + get selectedPaths(): Set { + return new Set(this.selectedFileIds.map((f) => f.path)); + } + + get hasSelectionInList(): boolean { + return this.changes.some((change) => this.selectedPaths.has(change.path)); + } + + 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; + } +} From 2c19add40a9cc2dc0785d462aabf79652bc31907 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Tue, 17 Mar 2026 20:01:40 +0200 Subject: [PATCH 02/10] Decompose StackView into compound components with StackController Split StackView into StackPanel, StackDetails, and StackCodegen with a StackController that owns shared reactive state (selection, preview, cross-panel coordination). Centralizes lifecycle concerns and reduces prop drilling between panels. --- .../src/components/StackCodegen.svelte | 164 ++++ .../src/components/StackDetails.svelte | 332 +++++++ apps/desktop/src/components/StackPanel.svelte | 296 +++++++ apps/desktop/src/components/StackView.svelte | 813 ++---------------- .../src/lib/stack/stackController.svelte.ts | 230 +++++ 5 files changed, 1090 insertions(+), 745 deletions(-) create mode 100644 apps/desktop/src/components/StackCodegen.svelte create mode 100644 apps/desktop/src/components/StackDetails.svelte create mode 100644 apps/desktop/src/components/StackPanel.svelte create mode 100644 apps/desktop/src/lib/stack/stackController.svelte.ts diff --git a/apps/desktop/src/components/StackCodegen.svelte b/apps/desktop/src/components/StackCodegen.svelte new file mode 100644 index 00000000000..228dfb7e4c1 --- /dev/null +++ b/apps/desktop/src/components/StackCodegen.svelte @@ -0,0 +1,164 @@ + + + + { + mcpConfigModal?.open(); + }} + {onAbort} + {initialPrompt} + events={events.response || []} + permissionRequests={permissionRequests.response || []} + onSubmit={sendMessage} + onChange={(prompt) => messageSender?.setPrompt(prompt)} + sessionId={sessionIdQuery.response} + {isStackActive} + {hasRulesToClear} + projectRegistered={claudeConfig.projectRegistered} + onRetryConfig={async () => { + await claudeCodeService.fetchClaudeConfig( + { projectId: controller.projectId }, + { forceRefetch: true }, + ); + }} + onAnswerQuestion={handleAnswerQuestion} +/> + + + + {#snippet children(config, { stackId: resolvedStackId })} + {@const resolvedLaneState = resolvedStackId + ? controller.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..2d0e6bc3d5d --- /dev/null +++ b/apps/desktop/src/components/StackDetails.svelte @@ -0,0 +1,332 @@ + + + +
+ {#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, + stackService, + hooksService, + uiState: controller.uiState, + 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 activeLastAdded} + controller.closePreview()} + startIndex={activeLastAdded ? get(activeLastAdded)?.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..e0c1f719097 --- /dev/null +++ b/apps/desktop/src/components/StackPanel.svelte @@ -0,0 +1,296 @@ + + + +
+ + +
+
+ { + 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} + + { + controller.clearWorktreeSelection(); + }} + onFileClick={(index) => { + controller.jumpToIndex(index); + }} + visibleRange={controller.visibleRange} + /> +
+ + diff --git a/apps/desktop/src/components/StackView.svelte b/apps/desktop/src/components/StackView.svelte index 19803d262b3..32c2da45d60 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/lib/stack/stackController.svelte.ts b/apps/desktop/src/lib/stack/stackController.svelte.ts new file mode 100644 index 00000000000..bd012b62485 --- /dev/null +++ b/apps/desktop/src/lib/stack/stackController.svelte.ts @@ -0,0 +1,230 @@ +/** + * 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 { + createBranchSelection, + createCommitSelection, + createWorktreeSelection, + readKey, + type SelectionId, +} from "$lib/selection/key"; +import { UI_STATE } from "$lib/state/uiState.svelte"; +import { inject } from "@gitbutler/core/context"; +import { getContext, setContext } from "svelte"; +import { get } from "svelte/store"; +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 { + /** Exposed for compound children that need direct uiState access (e.g. drop handlers). */ + uiState; + private idSelection: FileSelectionManager; + private getProjectId: () => string; + private getStackId: () => string | undefined; + private getLaneId: () => string; + + /** Whether this stack's focusable region is active. */ + active = $state(false); + + /** Visible range from MultiDiffView, consumed by WorktreeChanges for notching. */ + visibleRange = $state<{ start: number; end: number } | undefined>(); + + /** Cross-panel diff view coordination. */ + private diffJumpHandler?: (index: number) => void; + private diffPopoutHandler?: () => void; + + constructor(params: { + projectId: () => string; + stackId: () => string | undefined; + laneId: () => string; + }) { + this.uiState = inject(UI_STATE); + this.idSelection = inject(FILE_SELECTION_MANAGER); + this.getProjectId = params.projectId; + this.getStackId = params.stackId; + this.getLaneId = params.laneId; + } + + // ── Identity ────────────────────────────────────────────────────── + + 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 + ); + } + + // ── Active selection ID (for file selection tracking) ───────────── + + 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 activeLastAdded() { + if (this.activeSelectionId) { + return this.idSelection.getById(this.activeSelectionId).lastAdded; + } + return undefined; + } + + get selectedFile() { + const lastAdded = this.activeLastAdded; + if (!lastAdded) return undefined; + const value = get(lastAdded); + return value?.key ? readKey(value.key) : undefined; + } + + private get assignedSelection() { + return this.idSelection.getById(createWorktreeSelection({ stackId: this.stackId })); + } + + get lastAddedAssigned() { + return this.assignedSelection.lastAdded; + } + + get assignedKey() { + const value = get(this.lastAddedAssigned); + return value?.key ? readKey(value.key) : undefined; + } + + get hasActiveSelection(): boolean { + return !!(this.branchName || this.commitId || this.selectedFile); + } + + get isPreviewOpenForSelection(): boolean { + return this.hasActiveSelection && !!this.previewOpen; + } + + get hasAssignedFiles(): boolean { + return !!this.assignedKey; + } + + get ircPanelOpen(): boolean { + return this.selection.current?.irc === true; + } + + get isDetailsViewOpen(): boolean { + return this.isPreviewOpenForSelection || this.hasAssignedFiles || this.ircPanelOpen; + } + + closePreview(): void { + if (this.activeSelectionId) { + this.idSelection.clear(this.activeSelectionId); + } + this.selection.set(undefined); + } + + clearWorktreeSelection(): void { + this.idSelection.clear({ type: "worktree", stackId: this.stackId }); + } + + // ── Cross-panel diff coordination ───────────────────────────────── + + 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; + } +} From 3a090e5f9538218a0123e20e8ba2896697903b65 Mon Sep 17 00:00:00 2001 From: Mattias Granlund Date: Tue, 17 Mar 2026 20:40:43 +0200 Subject: [PATCH 03/10] Read from StackController context in branch components Replace prop drilling of identity and UI state in BranchList and BranchCommitList with getStackContext(). Move callback definitions into the components that use them and remove dead conflict resolution UI. --- .../src/components/BranchCommitList.svelte | 107 ++++++------ apps/desktop/src/components/BranchList.svelte | 157 ++++-------------- apps/desktop/src/components/StackPanel.svelte | 15 +- 3 files changed, 80 insertions(+), 199 deletions(-) diff --git a/apps/desktop/src/components/BranchCommitList.svelte b/apps/desktop/src/components/BranchCommitList.svelte index 2f4dfaa8e1a..41c9d4e0b98 100644 --- a/apps/desktop/src/components/BranchCommitList.svelte +++ b/apps/desktop/src/components/BranchCommitList.svelte @@ -32,9 +32,10 @@ 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 +46,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 +89,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 +210,7 @@ {/snippet} {#snippet commitReorderDz(dropzone: ReorderCommitDzHandler)} - {#if !isCommitting} + {#if !controller.isCommitting} {#snippet overlay({ hovered, activated })} @@ -247,7 +242,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 +278,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, @@ -313,15 +308,15 @@ stackId, stackService, hooksService, - uiState, + uiState: controller.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 +373,7 @@ {lastBranch} {selected} {tooltip} - {active} + active={controller.active} reactions={commitReactions[commit.id]} onclick={() => handleCommitClick(commit.id, false)} disableCommitActions={false} @@ -391,8 +386,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 +451,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/BranchList.svelte b/apps/desktop/src/components/BranchList.svelte index 6497a344083..3403b6ebbc3 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,46 @@ 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, ); @@ -206,7 +141,7 @@ ? claudeCodeService.messages({ projectId, stackId }) : undefined} {@const startCommittingDz = new StartCommitDzHandler( - uiState, + controller.uiState, uncommittedService, projectId, stackId, @@ -218,7 +153,7 @@ {stackId} {branchName} {lineColor} - {isCommitting} + isCommitting={controller.isCommitting} {baseBranchName} {stackService} prService={forge.current.prService} @@ -233,7 +168,7 @@ {branchName} {lineColor} {first} - {isCommitting} + isCommitting={controller.isCommitting} {iconName} {selected} {isNewBranch} @@ -254,18 +189,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 +209,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 +219,7 @@ await tick(); addDependentBranchModal?.show(); }} - disabled={isReadOnly} + disabled={controller.isReadOnly} /> {/if} @@ -296,14 +231,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 +277,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 +314,7 @@ {stackId} {status} selected={codegenSelected} - {onclick} + onclick={() => controller.clearWorktreeSelection()} /> {/if} {/if} @@ -416,17 +351,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 +372,6 @@ {#snippet branchContent()} 0} - {active} - {visibleRange} - {handleUncommit} - {startEditingCommitMessage} - {onclick} - {onFileClick} /> {/snippet} @@ -461,33 +387,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} {/if} - { - controller.clearWorktreeSelection(); - }} - onFileClick={(index) => { - controller.jumpToIndex(index); - }} - visibleRange={controller.visibleRange} - /> +