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
81 changes: 54 additions & 27 deletions README.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/Capacitor.App/Services/ImportDiscoveryFolder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Capacitor.App.Services;

/// <summary>A folder whose sessions matched no repository, as <c>kcap import --discover --json</c> reports it.</summary>
/// <param name="Folder">The value <c>kcap import --folder</c> takes; null counts sessions with no folder recorded.</param>
/// <param name="Vendors">Null from a CLI that does not split a folder by harness.</param>
public sealed record ImportDiscoveryFolder(
string? Folder, int Sessions, IReadOnlyList<ImportDiscoveryWindow>? Windows = null,
IReadOnlyList<ImportDiscoveryVendor>? Vendors = null);
4 changes: 3 additions & 1 deletion src/Capacitor.App/Services/ImportDiscoveryRepo.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
namespace Capacitor.App.Services;

/// <param name="Vendors">Null from a CLI that does not split a repository by harness.</param>
public sealed record ImportDiscoveryRepo(
string Owner, string Name, int Sessions, DateTimeOffset? LastSessionAt, IReadOnlyList<ImportDiscoveryWindow>? Windows) {
string Owner, string Name, int Sessions, DateTimeOffset? LastSessionAt, IReadOnlyList<ImportDiscoveryWindow>? Windows,
IReadOnlyList<ImportDiscoveryVendor>? Vendors = null) {
public string Slug => $"{Owner}/{Name}";
}
17 changes: 13 additions & 4 deletions src/Capacitor.App/Services/ImportDiscoveryReport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,31 @@

namespace Capacitor.App.Services;

/// <param name="UnmatchedFolders">Empty, or null when built by hand, from a CLI that does not list them.</param>
public sealed record ImportDiscoveryReport(
IReadOnlyList<ImportDiscoveryRepo> Repos,
int UnmatchedSessions,
IReadOnlyList<ImportDiscoveryWindow> Windows) {
IReadOnlyList<ImportDiscoveryWindow> Windows,
IReadOnlyList<ImportDiscoveryFolder>? UnmatchedFolders = null) {
/// Null for output that is not the report — an older CLI, or a crash mid-write.
public static ImportDiscoveryReport? Parse(string json) {
try {
var report = JsonSerializer.Deserialize(json, ImportDiscoveryReportJson.Default.ImportDiscoveryReport);
if (report?.Repos is null || report.UnmatchedSessions < 0 ||
report.Repos.Any(r => r is null || string.IsNullOrWhiteSpace(r.Owner) ||
string.IsNullOrWhiteSpace(r.Name) || r.Sessions < 0 ||
r.Windows?.Any(w => w is null || w.Sessions < 0) == true) ||
report.Windows?.Any(w => w is null || w.Sessions < 0) == true) return null;
return report with { Windows = report.Windows ?? [] };
Invalid(r.Windows) || Invalid(r.Vendors)) ||
report.UnmatchedFolders?.Any(f => f is null || f.Sessions < 0 || f.Folder?.Length == 0 ||
Invalid(f.Windows) || Invalid(f.Vendors)) == true ||
Invalid(report.Windows)) return null;
return report with { Windows = report.Windows ?? [], UnmatchedFolders = report.UnmatchedFolders ?? [] };
} catch (JsonException) {
return null;
}
}

static bool Invalid(IReadOnlyList<ImportDiscoveryWindow>? windows) => windows?.Any(w => w is null || w.Sessions < 0) == true;

static bool Invalid(IReadOnlyList<ImportDiscoveryVendor>? vendors) =>
vendors?.Any(v => v is null || string.IsNullOrWhiteSpace(v.Vendor) || v.Sessions < 0 || Invalid(v.Windows)) == true;
}
6 changes: 6 additions & 0 deletions src/Capacitor.App/Services/ImportDiscoveryVendor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Capacitor.App.Services;

/// <summary>One harness's share of a repository's history, as <c>kcap import --discover --json</c> reports it.</summary>
/// <param name="Vendor">The id the harness's own vendor flag is spelled with.</param>
public sealed record ImportDiscoveryVendor(
string Vendor, int Sessions, DateTimeOffset? LastSessionAt, IReadOnlyList<ImportDiscoveryWindow>? Windows);
33 changes: 20 additions & 13 deletions src/Capacitor.App/Services/KcapCli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ public enum ImportScopeChoice { Everything, Org, Repo }
/// <param name="Since">Only sessions started on or after this day.</param>
/// <param name="Private">Mark every imported session as visible to its owner only.</param>
/// <param name="SkipTitle">Leave titling to the server rather than this machine's own agent.</param>
/// <param name="Folders">With <see cref="ImportScopeChoice.Repo"/>, folders that matched no repository to
/// import beside <paramref name="Repos"/>.</param>
public sealed record ImportRequest(
ImportScopeChoice Scope, string? OrgOrRepo, IReadOnlyList<string> VendorFlags,
IReadOnlyList<string>? Repos = null, DateOnly? Since = null, bool Private = false, bool SkipTitle = false);
IReadOnlyList<string>? Repos = null, DateOnly? Since = null, bool Private = false, bool SkipTitle = false,
IReadOnlyList<string>? Folders = null);

/// Typed facade over every CLI call the app shells out to — every daemon mutation goes through
/// the CLI. Consumed by the lifecycle controller and faked in tests behind
Expand Down Expand Up @@ -112,7 +115,8 @@ Task<StreamingResult> SetupLocalAsync(
/// `local start`, bounded above the CLI's own readiness budget.
Task<ProcessResult> LocalStartAsync(CancellationToken ct);

/// `import --discover --json` for these vendors; null when the run failed or printed no report.
/// `import --discover --json` for these vendors, or every harness when none is named; null when the
/// run failed or printed no report.
Task<ImportDiscoveryReport?> ImportDiscoverAsync(IReadOnlyList<string> vendorFlags, CancellationToken ct);
}

Expand Down Expand Up @@ -313,20 +317,23 @@ public async Task<StreamingResult> ImportAsync(ImportRequest request, Action<Str
return new StreamingResult(-1, false, [new StreamedLine(ProcessStreamKind.Stderr, "kcap CLI not found")]);

List<string> args = ["import"];
args.Add(request.Scope switch {
ImportScopeChoice.Everything => "--all",
ImportScopeChoice.Org => "--org",
ImportScopeChoice.Repo => "--repo",
_ => throw new ArgumentOutOfRangeException(nameof(request)),
});
if (request is { Scope: ImportScopeChoice.Repo, Repos: { Count: > 0 } repos }) {
args.Add(repos[0]);
foreach (var repo in repos.Skip(1)) {
if (request is { Scope: ImportScopeChoice.Repo } && (request.Repos is { Count: > 0 } || request.Folders is { Count: > 0 })) {
foreach (var repo in request.Repos ?? []) {
args.Add("--repo");
args.Add(repo);
}
} else if (request.Scope != ImportScopeChoice.Everything) {
args.Add(request.OrgOrRepo!);
foreach (var folder in request.Folders ?? []) {
args.Add("--folder");
args.Add(folder);
}
} else {
args.Add(request.Scope switch {
ImportScopeChoice.Everything => "--all",
ImportScopeChoice.Org => "--org",
ImportScopeChoice.Repo => "--repo",
_ => throw new ArgumentOutOfRangeException(nameof(request)),
});
if (request.Scope != ImportScopeChoice.Everything) args.Add(request.OrgOrRepo!);
}
if (request.Since is { } since) {
args.Add("--since");
Expand Down
3 changes: 1 addition & 2 deletions src/Capacitor.App/Services/Onboarding/WizardComposition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,7 @@ internal static WizardGraph BuildGraph(WizardGraphOptions options) {
() => options.ResolveIdentity()?.Profile, options.ShutdownToken,
onLocalPath: OnLocalPath,
visibilityOptions: options.ReadVisibilityOptions);
var history = new HistoryStepViewModel(
cli, () => harnesses.Recording, options.Bridges.Post, machine, options.Time, OnLocalPath);
var history = new HistoryStepViewModel(cli, options.Bridges.Post, machine, options.Time, OnLocalPath);
var daemon = new DaemonEnabler(
cli, options.RunMutation,
// Gated on a committed sign-in or local install and resolved fresh per call, never the startup-cached profile.
Expand Down
58 changes: 58 additions & 0 deletions src/Capacitor.App/ViewModels/Onboarding/HistoryFolderRow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Capacitor.App.Services;
using ReactiveUI.Reactive;

namespace Capacitor.App.ViewModels.Onboarding;

/// <summary>
/// A folder whose sessions matched no repository, offered on the History page apart from the repositories.
/// Unchecked until the user checks it, so an untouched page imports the repositories alone, and Only me
/// once checked: with no repository there is no owner to tell who else should read it.
/// </summary>
public sealed class HistoryFolderRow : ReactiveObject {
bool _included;
bool _onlyMe = true;

internal HistoryFolderRow(
string path, int sessions, IReadOnlyList<ImportDiscoveryWindow>? windows,
IReadOnlyList<ImportDiscoveryVendor>? vendors, bool local) {
Path = path;
Sessions = sessions;
Windows = windows ?? [];
Vendors = vendors;
Local = local;
}

public string Path { get; }
public int Sessions { get; }
public bool Local { get; }

internal IReadOnlyList<ImportDiscoveryWindow> Windows { get; }

/// The folder's sessions split by harness, or null when the report did not split it.
internal IReadOnlyList<ImportDiscoveryVendor>? Vendors { get; }

public string SignalLine => $"{HistoryStepViewModel.SessionCount(Sessions)} on disk · no repository";

public bool Included {
get => _included;
set {
if (_included == value) return;
this.RaiseAndSetIfChanged(ref _included, value);
this.RaisePropertyChanged(nameof(Level));
}
}

public bool OnlyMe {
get => _onlyMe || Local;
set {
if (Local || _onlyMe == value) return;
this.RaiseAndSetIfChanged(ref _onlyMe, value);
this.RaisePropertyChanged(nameof(Level));
}
}

public ImportLevel Level => !Included ? ImportLevel.Skip : OnlyMe ? ImportLevel.OnlyMe : ImportLevel.Shared;

/// Sessions inside the window that starts on <paramref name="since"/>, or null when the report did not count it.
internal int? SessionsSince(DateOnly? since) => Windows.FirstOrDefault(w => w.Since == since)?.Sessions;
}
7 changes: 7 additions & 0 deletions src/Capacitor.App/ViewModels/Onboarding/HistoryRepoRow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public ImportLevel Level {
}
}

/// Carries the choice made on the row this one replaces, skipped or not, with the audience it would
/// import at.
internal void Adopt(HistoryRepoRow previous) {
_imported = previous._imported;
Level = previous.Level;
}

/// Checking a repository again brings back the audience it had before it was skipped.
public bool Included {
get => Level != ImportLevel.Skip;
Expand Down
29 changes: 29 additions & 0 deletions src/Capacitor.App/ViewModels/Onboarding/HistorySourceOption.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Capacitor.Cli.Core.Harness;
using ReactiveUI.Reactive;

namespace Capacitor.App.ViewModels.Onboarding;

/// One harness whose history the History page found, and whether its sessions are part of the import.
/// Independent of whether the harness records from now on.
public sealed class HistorySourceOption : ReactiveObject {
bool _isSelected = true;

internal HistorySourceOption(HarnessId id, int sessions) {
Id = id;
Label = HarnessRegistry.LabelOf(id);
Sessions = sessions;
}

public HarnessId Id { get; }
public string Label { get; }
public int Sessions { get; }

public string SignalLine => HistoryStepViewModel.SessionCount(Sessions);

public string AutomationName => $"Import history from {Label}";

public bool IsSelected {
get => _isSelected;
set => this.RaiseAndSetIfChanged(ref _isSelected, value);
}
}
Loading
Loading