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
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# The kept copy of the snapshot file is compared byte for byte, line endings included, so git has
# to hand it over exactly as it was committed on every machine and every CI runner. Without this a
# checkout with core.autocrlf would rewrite it and the test would fail on bytes nobody changed.
tests/Bws.Core.Tests/Golden/* -text
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ is not part of this repository.
- When a plan touches several entries this machine does not work without, the sheet names them all
in one sentence instead of one sentence each. On a plan over a whole scope those sentences used
to push every step off the sheet.
- The plan for a large selection appears sooner. Working out what stopping or restarting it
involves asks Windows about each entry's dependents once instead of several times, and the
plan for stopping every service on the test machine appeared in about 100-170 ms instead of
about 185-275.

### Fixed

- A plan over a selection that includes an entry this machine does not work without - stopping
a whole scope, for example - asked for a name to be typed, and typing it changed nothing: the
button stayed off. Such a selection cannot be carried out, and the sheet now says so, with the
entries named above it and a sentence saying to deselect them or deal with each one on its own.
A plan for one such entry still asks for its name, as before.

## [0.3.0] - 2026-09-25

Expand Down
11 changes: 7 additions & 4 deletions src/Bws.Core/Planning/BulkPlanBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@ public BulkPlan Build(BulkAction action)
// what somebody asked keeps its repeats, because it is a record.
var asked = action.ServiceNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList();

var builder = new PlanBuilder(entries, catalog, processes);
// One answer per name for the whole of this build - the ordering and every plan below ask
// about the same names, and DependentsAskedOnce says what that cost before it was shared.
var asking = new DependentsAskedOnce(catalog);
var builder = new PlanBuilder(entries, asking, processes);
var plans = new List<OperationPlan>();
var problems = new List<PlanProblem>();

foreach (var name in InTheOrderTheyMustHappen(action, asked))
foreach (var name in InTheOrderTheyMustHappen(action, asked, asking))
{
var plan = builder.Build(new ServiceAction(
action.Kind, name, action.IncludeDependents, action.To, AlsoStop: action.AlsoStop));
Expand Down Expand Up @@ -99,8 +102,8 @@ public BulkPlan Build(BulkAction action)
/// to stop in the order a plain stop of both would use - or the first stop meets the second
/// entry still running and the manager refuses it.
/// </summary>
private List<string> InTheOrderTheyMustHappen(BulkAction action, List<string> asked) =>
private static List<string> InTheOrderTheyMustHappen(BulkAction action, List<string> asked, IScmCatalog asking) =>
action.Kind is ActionKind.Start || (action.Kind == ActionKind.SetStartType && !action.AlsoStop)
? asked
: DependentsFirst.Order(catalog, asked);
: DependentsFirst.Order(asking, asked);
}
2 changes: 1 addition & 1 deletion src/Bws.Core/Planning/CriticalEntries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ [.. affected.Where(entry => WithoutTheseTheMachineStops.Contains(entry.ServiceNa
/// <b>A warning rather than a refusal, on the owner's decision of 2026-09-06.</b> An
/// administrator has the right to manage their own machine, which is the line `R2` of the
/// specification already draws. The window makes its own decision about how heavy a
/// confirmation to ask for - see <c>Planned.NeedsTyping</c> - and that is the window's, not
/// confirmation to ask for - see <c>Planned.AsksHeavily</c> - and that is the window's, not
/// this.
/// </summary>
internal static void AddWarnings(
Expand Down
46 changes: 46 additions & 0 deletions src/Bws.Core/Planning/DependentsAskedOnce.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace Bws.Core.Planning;

/// <summary>
/// The catalogue, with "who depends on this name" put to the manager once for the whole of one
/// bulk plan.
///
/// <b>Why it exists, and what it was measured to save (S-7 of the performance report, built
/// 2026-09-29).</b> A bulk plan asks the same question many times over. <see cref="DependentsFirst.Order"/>
/// asks once per selected name to put the selection in order, then each plan asks again about its
/// own target, and the cascades of neighbouring entries overlap. tools/plan-probe counted it on the
/// whole listing of 800 entries asked to stop: 1433 questions about 800 names, each one opening the
/// manager and then the service.
///
/// <b>One answer per name for one build, and no longer.</b> A preview is a picture of one moment, so
/// a name that has answered once answers the same for the rest of that picture. That includes a
/// refusal - asked again a moment later it could come out differently and leave two plans in one
/// preview disagreeing about the same entry. It lives exactly as long as the build that made it and
/// is never handed on, so nothing it remembers can go stale between two presses.
///
/// <b>Names compared as written, not without case.</b> The manager ignores case, so two spellings of
/// one name would at worst be asked twice, which costs one question. Folding them together would
/// instead be safe only because a real machine cannot hold two names that differ in case - the fake
/// catalogue deliberately does - and a rule that holds only while the input is real is the kind that
/// breaks where it is not.
///
/// Not safe for two threads at once, and not asked to be: one build runs on one thread.
/// </summary>
internal sealed class DependentsAskedOnce(IScmCatalog catalog) : IScmCatalog
{
private readonly Dictionary<string, Reading<IReadOnlyList<string>>> _answers = new(StringComparer.Ordinal);

public IReadOnlyList<ScmEntry> ReadAll() => catalog.ReadAll();

public IReadOnlyList<ScmStatus> ReadStatuses() => catalog.ReadStatuses();

public Reading<IReadOnlyList<string>> ReadDependents(string serviceName)
{
if (!_answers.TryGetValue(serviceName, out var answer))
{
answer = catalog.ReadDependents(serviceName);
_answers[serviceName] = answer;
}

return answer;
}
}
2 changes: 1 addition & 1 deletion src/Bws.Core/Planning/PlanWarnings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public enum PlanWarningKind
///
/// <b>What the wording has to carry is the glossary's distinction `P1`</b>: this is "you should
/// not", which is a different sentence from "you cannot" and from "confirm that you mean it".
/// <b>The window adds the third of those on its own</b> - see <c>Planned.NeedsTyping</c>, owner's
/// <b>The window adds the third of those on its own</b> - see <c>Planned.AsksHeavily</c>, owner's
/// decision of 2026-09-09 - and that is the window deciding how heavy a confirmation to ask
/// for, not this warning changing what it says.
/// </summary>
Expand Down
8 changes: 7 additions & 1 deletion src/Bws.Core/Snapshots/SnapshotJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,12 @@ private static JsonNode Sorted(JsonNode node)
return ordered;
}

return node.DeepClone();
// The leaf itself, not a copy of it. Both branches above take a child out of its old parent
// before handing it here, so it belongs to no tree and the new one can take it as it is.
// A copy of every value in the file is what this returned until 2026-09-29 (S-11 of the
// performance report). The kept copy in SnapshotGoldenTests is what says the bytes did
// not move - and a leaf that was still attached would throw on the first test, not
// write something wrong.
return node;
}
}
99 changes: 99 additions & 0 deletions src/Bws.Gui/PlanConfirmation.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<UserControl x:Class="Bws.Gui.PlanConfirmation"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Focusable="False"
IsTabStop="False">

<!--
THE HEAVY ASK AT THE FOOT OF A PLAN SHEET: why it is heavy, and what it takes to go ahead -
the entry's name typed back on one entry, or a refusal on a selection.

ITS OWN FILE SINCE 2026-09-29, AND THE SIZE RATCHET POINTED AT IT - PlanFooter.xaml stood one
line under the markup share that counts as "near", and the refusal of backlog 475 needed
lines. The seam is the one GUI rule 9 names rather than a block of lines: this section has a
state of its own (what was typed) and controls of its own, and the rest of the footer is
the buttons and what stands in their way.

Focusable and IsTabStop are off because a UserControl is a ContentControl and arrives with
both - `docs/10` trap 8, a silent extra Tab stop in front of the box.
-->

<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="WhenTrue" />
</UserControl.Resources>

<!--
THE HEAVIEST CONFIRMATION THIS PROJECT HAS, AND IT IS ALMOST NEVER ON THE SCREEN.
`docs/11` 9.2 point 2 puts the weight of an ask on the size of what it does, so this
appears only where ending a process takes down more than the entry somebody named,
or takes down one the machine will not run without. Measured 2026-08-01: 105 of 110
processes on this machine host exactly one service, so the ordinary forcing plan
shows the footer that shipped and nothing more.

THE WHOLE BLOCK GOES WHEN IT IS NOT WANTED, heading included - backlog 203, which
this window has already paid for once. A label reading "Type Spooler to confirm"
over a plan that never asked for it would leave the button grey with nothing on
screen explaining what would turn it on.
-->
<StackPanel x:Name="ConfirmSection"
Visibility="{Binding AsksHeavily, Converter={StaticResource WhenTrue}}">
<!--
WHY THE NAME HAS TO BE TYPED, OVER THE BOX IT HAS TO BE TYPED INTO, since
2026-09-16. The sentence saying the machine would go down used to stand in
"Worth knowing" - in the half of the sheet that scrolls, in plain white beside
"it will be back after the next restart" - and the label under it said only
"Type PlugPlay to confirm". The owner's words: the person does not know what
they are confirming. The argument at the head of PlanFooter.xaml for putting the box
in the footer - a confirmation somebody can skip past is not one - is the
argument for its reason standing here too, and it leaves "Worth knowing" rather
than being said twice.

THE BLOCK IS THE REFUSED ONE, NOT A THIRD. Its red rule has meant "stop and read
this" since 2026-09-02, and a block of its own in the same red would be a second
name for one look. The text style is the one place this sheet weighs a sentence:
DangerText, in Text.xaml, with the measurement beside it.
-->
<Border Style="{StaticResource PanelBlockRefused}"
Visibility="{Binding HasDanger, Converter={StaticResource WhenTrue}}">
<TextBlock x:Name="PlanDanger"
AutomationProperties.AutomationId="planDanger"
Text="{Binding Danger}"
TextWrapping="Wrap"
Style="{StaticResource DangerText}" />
</Border>

<!--
A SELECTION IS REFUSED RATHER THAN ASKED, backlog 475, the owner's decision of
2026-09-29. The sentence above names the entries, and this one says what to do about
them - in the place and the style of the label it stands in for, because it answers the
same question: what does it take to go ahead from here.
-->
<TextBlock x:Name="PlanRefusesSelection"
AutomationProperties.AutomationId="planRefusesSelection"
Text="{DynamicResource gui.plan.confirm.selection}"
TextWrapping="Wrap"
Visibility="{Binding RefusesSelection, Converter={StaticResource WhenTrue}}"
Style="{StaticResource SubduedText}" />

<!--
UpdateSourceTrigger IS THE WHOLE MECHANISM. A TextBox tells its binding on losing
focus by default, so without this line the name would be typed, the button would
stay grey, and it would come alive only when somebody clicked somewhere else -
which reads exactly like a confirmation that does not work.
-->
<StackPanel x:Name="TypingPart"
Visibility="{Binding NeedsTyping, Converter={StaticResource WhenTrue}}">
<TextBlock AutomationProperties.AutomationId="planConfirmLabel"
Text="{Binding TypeToConfirm}"
TextWrapping="Wrap"
Style="{StaticResource SubduedText}" />

<TextBox x:Name="ConfirmBox"
AutomationProperties.AutomationId="planConfirm"
AutomationProperties.Name="{Binding TypeToConfirm}"
ToolTip="{DynamicResource gui.plan.confirm.hint}"
Margin="{StaticResource MarginBelowSearch}"
Text="{Binding Typed, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</StackPanel>
</UserControl>
28 changes: 28 additions & 0 deletions src/Bws.Gui/PlanConfirmation.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Windows;
using System.Windows.Controls;

namespace Bws.Gui;

/// <summary>
/// The heavy ask at the foot of a plan sheet: the sentence saying why, and the entry's name typed
/// back - or, on a selection, the refusal that stands where the box would.
///
/// <b>Everything it shows is decided by <see cref="ViewModels.Planned"/></b>, as the footer around
/// it is. It left PlanFooter.xaml on 2026-09-29, when the refusal of backlog 475 needed room and
/// that file stood one line under the markup share the size ratchet calls near. What stays in this
/// class is what only a control can answer: which box to hand the keyboard, and whether it is there.
/// </summary>
public partial class PlanConfirmation : UserControl
{
public PlanConfirmation() => InitializeComponent();

/// <summary>The box the entry's name is typed into.</summary>
internal TextBox Box => ConfirmBox;

/// <summary>
/// Whether the box is on the screen - not the section. On a refused selection the section
/// stands with its reason and no box, and the keyboard has nowhere to go here.
/// </summary>
internal bool BoxShown => ConfirmSection.Visibility == Visibility.Visible
&& TypingPart.Visibility == Visibility.Visible;
}
60 changes: 5 additions & 55 deletions src/Bws.Gui/PlanFooter.xaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<UserControl x:Class="Bws.Gui.PlanFooter"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Bws.Gui"
Focusable="False"
IsTabStop="False">

Expand Down Expand Up @@ -96,62 +97,11 @@
</Border>

<!--
THE HEAVIEST CONFIRMATION THIS PROJECT HAS, AND IT IS ALMOST NEVER ON THE SCREEN.
`docs/11` 9.2 point 2 puts the weight of an ask on the size of what it does, so this
appears only where ending a process takes down more than the entry somebody named,
or takes down one the machine will not run without. Measured 2026-08-01: 105 of 110
processes on this machine host exactly one service, so the ordinary forcing plan
shows the footer that shipped and nothing more.

THE WHOLE BLOCK GOES WHEN IT IS NOT WANTED, heading included - backlog 203, which
this window has already paid for once. A label reading "Type Spooler to confirm"
over a plan that never asked for it would leave the button grey with nothing on
screen explaining what would turn it on.

UpdateSourceTrigger IS THE WHOLE MECHANISM. A TextBox tells its binding on losing
focus by default, so without this line the name would be typed, the button would
stay grey, and it would come alive only when somebody clicked somewhere else -
which reads exactly like a confirmation that does not work.
THE HEAVY ASK - the name typed back on one entry, the refusal on a selection - in a
file of its own since 2026-09-29. It has its own state and its own controls, and
PlanConfirmation.xaml says why it left and what it shows.
-->
<StackPanel x:Name="ConfirmSection"
Visibility="{Binding NeedsTyping, Converter={StaticResource WhenTrue}}">
<!--
WHY THE NAME HAS TO BE TYPED, OVER THE BOX IT HAS TO BE TYPED INTO, since
2026-09-16. The sentence saying the machine would go down used to stand in
"Worth knowing" - in the half of the sheet that scrolls, in plain white beside
"it will be back after the next restart" - and the label under it said only
"Type PlugPlay to confirm". The owner's words: the person does not know what
they are confirming. The argument at the head of this file for putting the box
in the footer - a confirmation somebody can skip past is not one - is the
argument for its reason standing here too, and it leaves "Worth knowing" rather
than being said twice.

THE BLOCK IS THE REFUSED ONE, NOT A THIRD. Its red rule has meant "stop and read
this" since 2026-09-02, and a block of its own in the same red would be a second
name for one look. The text style is the one place this sheet weighs a sentence:
DangerText, in Text.xaml, with the measurement beside it.
-->
<Border Style="{StaticResource PanelBlockRefused}"
Visibility="{Binding HasDanger, Converter={StaticResource WhenTrue}}">
<TextBlock x:Name="PlanDanger"
AutomationProperties.AutomationId="planDanger"
Text="{Binding Danger}"
TextWrapping="Wrap"
Style="{StaticResource DangerText}" />
</Border>

<TextBlock AutomationProperties.AutomationId="planConfirmLabel"
Text="{Binding TypeToConfirm}"
TextWrapping="Wrap"
Style="{StaticResource SubduedText}" />

<TextBox x:Name="ConfirmBox"
AutomationProperties.AutomationId="planConfirm"
AutomationProperties.Name="{Binding TypeToConfirm}"
ToolTip="{DynamicResource gui.plan.confirm.hint}"
Margin="{StaticResource MarginBelowSearch}"
Text="{Binding Typed, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<local:PlanConfirmation x:Name="Confirmation" />

<!--
TWO ROWS SINCE 2026-09-15: the controls, and under them the one sentence the box of
Expand Down
Loading
Loading