Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
Binary file removed .pnpm-store/v11/index.db
Binary file not shown.
1 change: 0 additions & 1 deletion .pnpm-store/v11/projects/739d8fce9f21779d2680f9e8d181f4ee

This file was deleted.

83 changes: 80 additions & 3 deletions app/lib/desktop/chat/desktop_sidebar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import '../../ui/docs/docs_popover.dart';
import '../../ui/docs/doc_preview.dart';
import '../../ui/ports/ports_popover.dart';
import '../../ui/ports/session_ports_glyph.dart';
import '../../ui/widgets/lands_in_picker.dart';
import '../../ui/widgets/pr_state_style.dart';
import '../../ui/project/folder_browser.dart';
import '../../ui/widgets/connection_chip.dart';
Expand Down Expand Up @@ -647,6 +648,27 @@ class _WorktreeGroupState extends ConsumerState<_WorktreeGroup> {
switch (action) {
case 'rename':
_renameBranch();
case 'landsIn':
// NOT `sheet: true`: desktop wants the
// dialog form of the picker, not a bottom
// sheet. It persists the choice itself.
// Re-derive the live worktree: a snapshot
// may have retargeted or moved it while the
// menu was open, so act on today's value.
final live =
ref
.read(reposProvider)
.locateWorktree(worktree.path)
?.worktree ??
worktree;
unawaited(
showLandsInPicker(
context,
ref,
projectId: repo.id,
worktree: live,
),
);
case 'delete':
_deleteWorktree();
case 'ports':
Expand All @@ -665,7 +687,9 @@ class _WorktreeGroupState extends ConsumerState<_WorktreeGroup> {
}
},
)
else if (worktree.hasChanges)
// See worktree_row.dart: suppress rather than
// publish a partial count.
else if (worktree.showsDiff)
DiffChip(
insertions: worktree.insertions,
deletions: worktree.deletions,
Expand Down Expand Up @@ -836,8 +860,12 @@ class _WorktreeGroupState extends ConsumerState<_WorktreeGroup> {
/// The worktree row's hover overflow menu (triple dots that replace the diff
/// pill on hover). Reports the chosen action up to [_WorktreeGroupState], which
/// owns the dialogs and store calls with a context/ref that outlives the menu.
/// "Rename branch" and "Delete worktree" are disabled for the primary worktree;
/// "Rename branch" is also disabled for detached worktrees and open PRs.
/// "Rename branch", "Lands in" and "Delete worktree" are disabled for the
/// primary worktree and for detached worktrees; "Rename branch" is additionally
/// disabled for an open PR, but "Lands in" is NOT (retargeting an open PR is a
/// first-class operation). Because this menu replaces the diff pill on hover,
/// "Lands in" prints the current target inline — the pill is not visible to
/// glance at while the menu is open.
class _WorktreeMenuButton extends ConsumerWidget {
const _WorktreeMenuButton({
required this.worktree,
Expand All @@ -856,6 +884,11 @@ class _WorktreeMenuButton extends ConsumerWidget {
final isPrimary = worktree.isPrimary;
final isDetached = worktree.branch == null;
final canRename = !_hasOpenPr && !isPrimary && !isDetached;
// Retargeting shares rename's structural guards (no primary, no detached)
// but pointedly NOT its open-PR block: renaming orphans a PR's head, while
// retargeting an open PR is a first-class operation (`gh pr edit --base`).
// So an open PR leaves this enabled while it disables Rename — deliberate.
final canRetarget = !isPrimary && !isDetached;
// The count is a glance at what this branch is serving; the item routes to
// the global Ports screen either way (D8), so it shows even at zero.
final portCount = ref.watch(portsForWorktreeProvider(worktree.path)).length;
Expand Down Expand Up @@ -891,6 +924,50 @@ class _WorktreeMenuButton extends ConsumerWidget {
),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'landsIn',
enabled: canRetarget,
height: 36,
child: Tooltip(
message: isPrimary
? 'This is where branches land, not one that lands'
: isDetached
? 'This worktree has no branch to land'
: '',
// Bare Text + a trailing value, no leading icon — matching the
// other items in this menu. The `⋯` button REPLACES the diff pill
// on hover, so the user cannot glance back at the pill for the
// current target while this menu is open; the item prints its own
// current value on the right instead.
child: Row(
children: [
Text(
'Lands in',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: canRetarget ? null : Theme.of(context).disabledColor,
),
),
const Spacer(),
if (worktree.targetBranch != null) ...[
const SizedBox(width: kSpace12),
Flexible(
child: Text(
worktree.targetBranch!,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.mono
.copyWith(
color: canRetarget
? Theme.of(context).colorScheme.outline
: Theme.of(context).disabledColor,
),
),
),
],
],
),
),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'delete',
enabled: !isPrimary,
Expand Down
22 changes: 11 additions & 11 deletions app/lib/desktop/chat/new_worktree_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
String? _projectId;
_WorktreeFrom _source = _WorktreeFrom.newBranch;
String? _existingWorktreePath;
String? _baseBranch;
String? _targetBranch;
int? _prNumber;
Future<List<OpenPr>>? _prsFuture;

Expand All @@ -81,7 +81,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
widget.initialProjectId ??
_activeGroupRepo() ??
(repos.isNotEmpty ? repos.first.id : null);
_baseBranch = _defaultBranchFor(_projectId);
_targetBranch = _defaultBranchFor(_projectId);
}

@override
Expand Down Expand Up @@ -115,18 +115,18 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
return null;
}

/// The base branch actually used: [_baseBranch] when it is a live option,
/// The target branch actually used: [_targetBranch] when it is a live option,
/// else the first option (what [_newBranchPanel] displays).
String? _effectiveBaseBranch(String? projectId) {
String? _effectiveTargetBranch(String? projectId) {
RepoInfo? repo;
for (final r in ref.read(reposProvider).repos) {
if (r.id == projectId) repo = r;
}
final options = repo == null
? const <String>[]
: branchOptionsForRepo(repo);
if (options.isEmpty) return _baseBranch;
return options.contains(_baseBranch) ? _baseBranch : options.first;
if (options.isEmpty) return _targetBranch;
return options.contains(_targetBranch) ? _targetBranch : options.first;
}

void _close() {
Expand All @@ -148,7 +148,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
if (projectId == null || projectId == _projectId) return;
setState(() {
_projectId = projectId;
_baseBranch = _defaultBranchFor(projectId);
_targetBranch = _defaultBranchFor(projectId);
// A different repo has a different PR list; drop the cached future so the
// panel refetches when From PR is shown again.
_prsFuture = null;
Expand Down Expand Up @@ -200,7 +200,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
final name = _branchNameCtrl.text.trim();
created = await store.createWorktree(
projectId,
baseBranch: _effectiveBaseBranch(projectId),
targetBranch: _effectiveTargetBranch(projectId),
branchName: name.isEmpty ? null : name,
);
case _WorktreeFrom.fromPr:
Expand Down Expand Up @@ -499,8 +499,8 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
)
: DropdownButtonFormField<String>(
key: ValueKey('wt-branch-$_projectId'),
initialValue: options.contains(_baseBranch)
? _baseBranch
initialValue: options.contains(_targetBranch)
? _targetBranch
: options.first,
isExpanded: true,
items: [
Expand All @@ -512,7 +512,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
],
onChanged: _creating
? null
: (v) => setState(() => _baseBranch = v),
: (v) => setState(() => _targetBranch = v),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expand Down
69 changes: 55 additions & 14 deletions app/lib/desktop/chat/pr_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:phosphoricons_flutter/phosphoricons_flutter.dart';

import '../../app/theme.dart';
import '../../store/models.dart';
import '../../store/store.dart';
import '../../ui/widgets/icon_glyph.dart';
import '../../ui/widgets/pr_detail.dart';
import '../../ui/widgets/pr_signals.dart';
Expand Down Expand Up @@ -111,6 +112,10 @@ class PrComposerBar extends ConsumerWidget {
const SizedBox(width: kSpace10),
PrCtaButton(
status: status,
// Home 1: the caret menu's "This worktree" group needs an identity to
// name; without one the group is simply absent.
projectId: projectId,
worktreePath: worktreePath,
onRun: (remedy) => _run(context, ref, remedy),
),
],
Expand All @@ -121,22 +126,40 @@ class PrComposerBar extends ConsumerWidget {
context,
status: status,
pr: pr,
// Identity, so the sheet re-derives rather than freezing its facts.
projectId: projectId,
worktreePath: worktreePath,
onRun: (remedy) => _run(context, ref, remedy),
);

Future<void> _run(BuildContext context, WidgetRef ref, PrRemedy remedy) =>
runPrRemedy(
context,
ref,
remedy: remedy,
status: status,
pr: pr,
projectId: projectId,
worktreePath: worktreePath,
branch: branch,
uncommittedFiles: uncommittedFiles,
onInsertPrompt: onInsertPrompt,
);
Future<void> _run(
BuildContext context,
WidgetRef ref,
PrRemedy remedy,
) async {
// Re-derive from the snapshot at call time: the in-dialog "Lands in" picker
// can change the PR base (and the derived facts) while the dialog is open, so
// a remedy must act on today's target, not the build-time `status`/`pr`.
// Guard `context.mounted` first — the bar can be torn down by a snapshot and
// reading `ref` on a defunct element throws. Falls back to open-time values
// when the row is gone from the snapshot.
if (!context.mounted) return;
final at = ref.read(reposProvider).locateWorktree(worktreePath);
await runPrRemedy(
context,
ref,
remedy: remedy,
status: at == null ? status : prStatusFor(at),
pr: at == null ? pr : at.worktree.pr,
projectId: projectId,
worktreePath: worktreePath,
branch: at == null ? branch : at.worktree.branch,
uncommittedFiles: at == null
? uncommittedFiles
: at.worktree.uncommittedFiles,
onInsertPrompt: onInsertPrompt,
);
}
}

/// The sentence: a status dot, the PR number (or branch), and the loud fact.
Expand Down Expand Up @@ -269,10 +292,21 @@ class _MoreLink extends StatelessWidget {
/// * **agent prompt** — tonal fill in the fact's tone; inserts text,
/// * **direct op** — solid fill; runs now (behind a confirm when destructive).
class PrCtaButton extends ConsumerWidget {
const PrCtaButton({super.key, required this.status, required this.onRun});
const PrCtaButton({
super.key,
required this.status,
required this.onRun,
this.projectId,
this.worktreePath,
});

final PrStatus status;

/// Identity for the menu's "Lands in" entry (Home 1). Optional: a surface with
/// no resolvable worktree just does not show the group.
final String? projectId;
final String? worktreePath;

/// Every action — prompt or direct — goes through here; [runPrRemedy] decides
/// what each one means. This widget deliberately knows nothing about composers.
final void Function(PrRemedy remedy) onRun;
Expand Down Expand Up @@ -316,6 +350,13 @@ class PrCtaButton extends ConsumerWidget {
ref,
status: status,
onRun: onRun,
projectId: projectId,
// Resolved from the snapshot rather than passed in, so the inline value is
// whatever the latest broadcast says.
worktree: ref
.watch(reposProvider)
.locateWorktree(worktreePath)
?.worktree,
),
builder: (context, controller, _) => _SplitButton(
label: cta.label,
Expand Down
Loading
Loading