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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions apps/desktop/src/components/BranchCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import CreateReviewBox from '$components/CreateReviewBox.svelte';
import Dropzone from '$components/Dropzone.svelte';
import PrNumberUpdater from '$components/PrNumberUpdater.svelte';
import { BranchDropData } from '$lib/branches/dropHandler';
import { BranchDropData, StartCommitDzHandler } from '$lib/branches/dropHandler';
import { MoveCommitDzHandler } from '$lib/commits/dropHandler';
import { ReorderCommitDzHandler } from '$lib/dragging/stackingReorderDropzoneManager';
import { DEFAULT_FORGE_FACTORY } from '$lib/forge/forgeFactory.svelte';
Expand Down Expand Up @@ -150,6 +150,13 @@
});
}
}

function getCardOverlayLabel(handler: DropzoneHandler | undefined): string {
if (handler instanceof MoveCommitDzHandler) return 'Move here';
if (handler instanceof ReorderCommitDzHandler) return 'Reorder here';
if (handler instanceof StartCommitDzHandler) return 'Start commit';
return 'Drop here';
}
</script>

<div
Expand All @@ -171,12 +178,7 @@
handlers={args.first ? [moveHandler, ...args.dropzones].filter(isDefined) : args.dropzones}
>
{#snippet overlay({ hovered, activated, handler })}
{@const label =
handler instanceof MoveCommitDzHandler
? 'Move here'
: handler instanceof ReorderCommitDzHandler
? 'Reorder here'
: 'Start commit'}
{@const label = getCardOverlayLabel(handler)}
<CardOverlay {hovered} {activated} {label} />
{/snippet}

Expand Down
17 changes: 15 additions & 2 deletions apps/desktop/src/components/BranchList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import ReduxResult from '$components/ReduxResult.svelte';
import { getColorFromCommitState, getIconFromCommitState } from '$components/lib';
import { BASE_BRANCH_SERVICE } from '$lib/baseBranch/baseBranchService.svelte';
import { StartCommitDzHandler } from '$lib/branches/dropHandler';
import { CLAUDE_CODE_SERVICE } from '$lib/codegen/claude';
import { focusClaudeInput } from '$lib/codegen/focusClaudeInput';
import { currentStatus } from '$lib/codegen/messages';
Expand All @@ -22,6 +23,7 @@
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 { type BranchDetails } from '$lib/stacks/stack';
import { STACK_SERVICE } from '$lib/stacks/stackService.svelte';
import { combineResults } from '$lib/state/helpers';
Expand Down Expand Up @@ -53,6 +55,7 @@
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);
Expand Down Expand Up @@ -159,7 +162,10 @@
commitQuery.result
)}
>
{#snippet children([localAndRemoteCommits, upstreamOnlyCommits, branchDetails, commit])}
{#snippet children(
[localAndRemoteCommits, upstreamOnlyCommits, branchDetails, commit],
{ projectId, stackId }
)}
{@const firstBranch = i === 0}
{@const lastBranch = i === branches.length - 1}
{@const iconName = getIconFromCommitState(commit?.id, commit?.state)}
Expand Down Expand Up @@ -190,6 +196,13 @@
{@const codegenQuery = stackId
? claudeCodeService.messages({ projectId, stackId })
: undefined}
{@const startCommittingDz = new StartCommitDzHandler(
uiState,
uncommittedService,
projectId,
stackId,
branchName
)}
{#if stackId}
<BranchInsertion
{projectId}
Expand Down Expand Up @@ -228,7 +241,7 @@
numberOfUpstreamCommits={upstreamOnlyCommits.length}
numberOfBranchesInStack={branches.length}
baseCommit={branchDetails.baseCommit}
dropzones={[stackingReorderDropzoneManager.top(branchName)]}
dropzones={[stackingReorderDropzoneManager.top(branchName), startCommittingDz]}
trackingBranch={branch.remoteTrackingBranch ?? undefined}
readonly={!!branch.remoteTrackingBranch}
onclick={() => {
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/lib/branches/dropHandler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { FileChangeDropData, FolderChangeDropData, HunkDropDataV3 } from '$lib/dragging/draggables';
import { updateStackPrs } from '$lib/forge/shared/prFooter';
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(
Expand Down Expand Up @@ -62,3 +65,66 @@ export class MoveBranchDzHandler implements DropzoneHandler {
await updateStackPrs(this.prService, branchDetails, this.baseBranchName);
}
}

export class StartCommitDzHandler implements DropzoneHandler {
constructor(
private readonly uiState: UiState,
private readonly uncommittedService: UncommittedService,
private readonly projectId: string,
private readonly stackId: string | undefined,
private readonly branchName: string
) {}

print(): string {
return `StartCommitDzHandler(${this.projectId}, ${this.stackId}, ${this.branchName})`;
}

accepts(data: unknown): boolean {
if (data instanceof FileChangeDropData || data instanceof FolderChangeDropData) {
// Only accept uncomitted files/folders
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling error: "uncomitted" should be "uncommitted"

Suggested change
// Only accept uncomitted files/folders
// Only accept uncommitted files/folders

Copilot uses AI. Check for mistakes.
if (data.isCommitted) return false;
// Only accept unassinged files/folders or those assigned to the same stack
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling error: "unassinged" should be "unassigned"

Suggested change
// Only accept unassinged files/folders or those assigned to the same stack
// Only accept unassigned files/folders or those assigned to the same stack

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +86
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two spelling errors in the comments: "uncomitted" should be "uncommitted" and "unassinged" should be "unassigned".

Suggested change
// Only accept uncomitted files/folders
if (data.isCommitted) return false;
// Only accept unassinged files/folders or those assigned to the same stack
// Only accept uncommitted files/folders
if (data.isCommitted) return false;
// Only accept unassigned files/folders or those assigned to the same stack

Copilot uses AI. Check for mistakes.
if (data.stackId !== undefined && data.stackId !== this.stackId) return false;
return true;
}
if (data instanceof HunkDropDataV3) {
// Only accept uncommitted hunks
if (!data.uncommitted) return false;
if (data.selectionId.type !== 'worktree') return false;
// Only accept unassigned hunks or those assigned to the same stack
if (data.stackId !== undefined && data.stackId !== this.stackId) return false;
return true;
}
return false;
}

private startCommitting() {
const projectState = this.uiState.project(this.projectId);
projectState.exclusiveAction.set({
type: 'commit',
stackId: this.stackId,
branchName: this.branchName
});
}

private async checkDropData(
data: FileChangeDropData | FolderChangeDropData | HunkDropDataV3
): Promise<true> {
if (data instanceof FileChangeDropData || data instanceof FolderChangeDropData) {
const changes = await data.treeChanges();
const paths = changes.map((c) => c.path);
if (paths.length === 0) return true;
this.uncommittedService.checkFiles(data.stackId ?? null, paths);
return true;
}

// Handle hunk data
this.uncommittedService.checkHunk(data.stackId ?? null, data.change.path, data.hunk);
return true;
Comment on lines +112 to +123
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type Promise<true> is overly specific. Since this method always returns true and doesn't seem to need a return value, consider changing the return type to Promise<void> and removing the return true statements. This would make the intent clearer that this method is called for its side effects rather than its return value.

Suggested change
): Promise<true> {
if (data instanceof FileChangeDropData || data instanceof FolderChangeDropData) {
const changes = await data.treeChanges();
const paths = changes.map((c) => c.path);
if (paths.length === 0) return true;
this.uncommittedService.checkFiles(data.stackId ?? null, paths);
return true;
}
// Handle hunk data
this.uncommittedService.checkHunk(data.stackId ?? null, data.change.path, data.hunk);
return true;
): Promise<void> {
if (data instanceof FileChangeDropData || data instanceof FolderChangeDropData) {
const changes = await data.treeChanges();
const paths = changes.map((c) => c.path);
if (paths.length === 0) return;
this.uncommittedService.checkFiles(data.stackId ?? null, paths);
return;
}
// Handle hunk data
this.uncommittedService.checkHunk(data.stackId ?? null, data.change.path, data.hunk);
return;

Copilot uses AI. Check for mistakes.
}

async ondrop(data: FileChangeDropData | FolderChangeDropData | HunkDropDataV3): Promise<void> {
this.startCommitting();
await this.checkDropData(data);
Comment on lines +127 to +128
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order of operations may cause a race condition. The method calls startCommitting() to set the UI state to commit mode, then immediately calls checkDropData() to stage the changes. However, if the UI responds to the state change before the changes are checked/staged, users might see an empty commit form briefly. Consider awaiting checkDropData() before calling startCommitting() to ensure the changes are staged before the commit UI appears.

Suggested change
this.startCommitting();
await this.checkDropData(data);
await this.checkDropData(data);
this.startCommitting();

Copilot uses AI. Check for mistakes.
}
}
Loading