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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(default **10 seconds**; **0** closes immediately), and selecting **Never** turns auto-close
off entirely.

### Fixed

- **Opening a second tool on a freshly placed branch no longer fails.** Opening a **new worktree**
placement card created the worktree and launched into it — but the card still read *new worktree*,
so a second click (open a **Console**, then open **Rider**) tried to `git worktree add` the same
branch again and failed with *git worktree add failed*, since the branch was now checked out in the
worktree the first click made. Placing a branch now **converts the card in place** to the real
checkout it became — a created worktree becomes a deletable **worktree**, a switched clone becomes a
**main clone** — with its solution chips re-scanned from the tree that now exists. Any further tool
clicks open that folder directly, no rescan required.

### Changed

- **MRU suggestions no longer drop down on focus.** The Branch and Solution boxes used to open
Expand Down
26 changes: 26 additions & 0 deletions src/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,32 @@ public void RemoveTarget(TargetCard card)
Phase = DiscoveryPhase.NotFound;
}

/// <summary>
/// Swaps a just-placed card for the real checkout it became: a <see cref="TargetKind.NewWorktree"/>
/// whose worktree was created turns into a <see cref="TargetKind.Worktree"/>, a
/// <see cref="TargetKind.SwitchMainClone"/> whose main tree was switched turns into a
/// <see cref="TargetKind.MainClone"/>. Opening the card again then launches the folder that now
/// exists instead of trying to place the branch a second time — which git rejects, because the
/// branch is already checked out. Selection follows the swapped card so the just-opened target stays
/// current (and, for a new worktree, becomes deletable without waiting for a rescan).
/// </summary>
public void ReplaceTarget(TargetCard existing, DiscoveredTarget materialised)
{
var index = Targets.IndexOf(existing);
if (index < 0) return;

var wasSelected = ReferenceEquals(_selectedTarget, existing);
var card = new TargetCard(materialised);
Targets[index] = card;

// Replacing the selected item can bounce the list's SelectedItem through null; set it back
// explicitly so selection — and everything gated on it — lands on the materialised card.
if (wasSelected) SelectedTarget = card;

OnPropertyChanged(nameof(HasMultipleTargets));
OnPropertyChanged(nameof(MultiTargetHelperText));
}

// --- Auto-close countdown ---------------------------------------------------------

private bool _isClosingCountdown;
Expand Down
32 changes: 32 additions & 0 deletions src/Views/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,10 @@ internal async Task OpenWithAsync(Editor editor, bool fromCommandLine = false)
_vm.AppendLog($"▸ Creating a worktree for '{branch}' in {card.Target.RepoName}…");
var ctx = await _opener.BuildMainContextAsync(repo, branch, _config);
folder = await _opener.CreateWorktreeAsync(repo, branch, ctx);
// The worktree now exists on disk: convert the card to a real one so a second tool
// click opens it instead of trying to add the same worktree again (git would refuse —
// the branch is now checked out here).
await MaterialisePlacementAsync(card, folder, TargetKind.Worktree);
}
else if (card.Target.Kind == TargetKind.SwitchMainClone)
{
Expand All @@ -296,6 +300,9 @@ internal async Task OpenWithAsync(Editor editor, bool fromCommandLine = false)
_vm.AppendLog($"▸ Switching {card.Target.RepoName}'s main tree to '{branch}'…");
var ctx = await _opener.BuildMainContextAsync(repo, branch, _config);
folder = await _opener.CheckoutInMainAsync(repo, branch, ctx);
// The main tree is now on the branch: convert the card to a plain main-clone target so
// a second click just reopens it rather than re-running the switch.
await MaterialisePlacementAsync(card, folder, TargetKind.MainClone);
}
else
{
Expand Down Expand Up @@ -338,6 +345,31 @@ internal async Task OpenWithAsync(Editor editor, bool fromCommandLine = false)
}
}

/// <summary>
/// Replaces a placement card whose branch was just put on disk (worktree created, or clone
/// switched) with the real <paramref name="kind"/> checkout at <paramref name="folder"/>. Done as
/// soon as the placement succeeds — before the launch itself, which may fail to locate the editor —
/// so a follow-up click on the same card opens the existing folder instead of asking git to place
/// the branch again. The solutions are re-globbed from the tree that now exists (the placement card
/// only previewed the clone's), keeping the chip row honest for the next open.
/// </summary>
private async Task MaterialisePlacementAsync(TargetCard card, string folder, TargetKind kind)
{
DateTime? updated = null;
try { updated = Directory.GetLastWriteTimeUtc(folder); }
catch { /* advisory meta only — an unreadable timestamp shouldn't block the swap */ }

var solutions = await Task.Run(() => _opener.FindSolutionsInFolder(folder, _config));
var materialised = card.Target with
{
Path = folder,
Kind = kind,
Solutions = solutions,
UpdatedUtc = updated,
};
_vm.ReplaceTarget(card, materialised);
}

// --- Delete (inline two-step confirm) -------------------------------------------------

/// <summary>First click: build a fresh deletion plan (dirty files / orphaned commits included)
Expand Down
55 changes: 55 additions & 0 deletions tests/Fido.Tests/E2E/NewBranchRepoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,61 @@ await Harness.WithWindow(services, async window =>
});
}

/// <summary>
/// Regression: opening a placement card creates the worktree once and then <em>converts</em> the
/// card to the real checkout, so a second tool click (the user's "open a console, then open Rider")
/// launches the existing worktree instead of asking git to add it again — which git rejects because
/// the branch is already checked out there.
/// </summary>
[Test]
public async Task Opening_a_second_tool_reuses_the_created_worktree_instead_of_re_adding_it()
{
using var world = new TestRepoWorld();
var origin = world.CreateOrigin("Foo", "Foo");
var root = world.SearchRoot("root");
var clone = world.Clone(origin, root, "Foo");
world.PublishBranchToOrigin(origin, "feature/x"); // never fetched by the clone

var launcher = new FakeEditorLauncher();
var services = world.BuildServices([root], launcher, new FakeDialogService());

await Harness.WithWindow(services, async window =>
{
var vm = window.Vm();
await window.Discover("feature/x");
await AssertSingleCandidate(window, "feature/x", remoteOnly: true);

// First click opens a console on the (freshly created) worktree.
await window.OpenWithAsync(new Editor { Name = "Console", Kind = EditorKind.Console });
var worktree = vm.Targets[0].Path;
await Assert.That(Directory.Exists(worktree)).IsTrue();

// The card has become a real, deletable worktree — no rescan needed — and stays selected.
await Assert.That(vm.Targets[0].IsNewWorktree).IsFalse();
await Assert.That(vm.Targets[0].IsWorktree).IsTrue();
await Assert.That(vm.SelectedTarget!.IsWorktree).IsTrue();
await Assert.That(vm.CanDelete).IsTrue();

// Second click — Rider this time — must open the existing worktree, not fail on a re-add.
await window.OpenWithAsync(new Editor { Name = "Rider", Kind = EditorKind.Rider });

await Assert.That(window.LogText().Contains("git worktree add failed")).IsFalse();
await Assert.That(launcher.Launches.Count).IsEqualTo(2);

// Both launches landed inside the one worktree; no second, "-2" tree was created.
await Assert.That(Paths.StartsWith(launcher.Launches[0].Target, worktree)).IsTrue();
var riderTarget = launcher.Launches[1].Target;
await Assert.That(Paths.StartsWith(riderTarget, worktree)).IsTrue();
await Assert.That(riderTarget).EndsWith("Foo.sln");
await Assert.That(Directory.Exists(worktree + "-2")).IsFalse();

// Discovery still reports exactly one worktree for the branch.
await window.RunDiscoveryAsync();
await Assert.That(vm.Targets.Count).IsEqualTo(1);
await Assert.That(vm.Targets[0].IsWorktree).IsTrue();
});
}

[Test]
public async Task Opening_the_switch_card_moves_the_main_tree_onto_the_branch()
{
Expand Down
Loading