Skip to content

Commit 11a1351

Browse files
authored
Merge pull request #168 from Atypical-Consulting/storage-providers
feat: storage provider abstraction + FTP provider
2 parents a991030 + fa06f57 commit 11a1351

58 files changed

Lines changed: 2024 additions & 9 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Atypical.VirtualFileSystem.sln

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.
2323
EndProject
2424
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.GitHub.Tests", "tests\Atypical.VirtualFileSystem.GitHub.Tests\Atypical.VirtualFileSystem.GitHub.Tests.csproj", "{31971414-5519-4149-B5C0-C88454EBE7AD}"
2525
EndProject
26+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Providers.Abstractions", "src\Atypical.VirtualFileSystem.Providers.Abstractions\Atypical.VirtualFileSystem.Providers.Abstractions.csproj", "{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}"
27+
EndProject
28+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Providers.Abstractions.Tests", "tests\Atypical.VirtualFileSystem.Providers.Abstractions.Tests\Atypical.VirtualFileSystem.Providers.Abstractions.Tests.csproj", "{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}"
29+
EndProject
30+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Ftp", "src\Atypical.VirtualFileSystem.Ftp\Atypical.VirtualFileSystem.Ftp.csproj", "{9601A06C-4C43-4E68-8576-F92BBFFEA73B}"
31+
EndProject
32+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Ftp.Tests", "tests\Atypical.VirtualFileSystem.Ftp.Tests\Atypical.VirtualFileSystem.Ftp.Tests.csproj", "{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}"
33+
EndProject
2634
Global
2735
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2836
Debug|Any CPU = Debug|Any CPU
@@ -117,6 +125,54 @@ Global
117125
{31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x64.Build.0 = Release|Any CPU
118126
{31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x86.ActiveCfg = Release|Any CPU
119127
{31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x86.Build.0 = Release|Any CPU
128+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
129+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|Any CPU.Build.0 = Debug|Any CPU
130+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x64.ActiveCfg = Debug|Any CPU
131+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x64.Build.0 = Debug|Any CPU
132+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x86.ActiveCfg = Debug|Any CPU
133+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x86.Build.0 = Debug|Any CPU
134+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|Any CPU.ActiveCfg = Release|Any CPU
135+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|Any CPU.Build.0 = Release|Any CPU
136+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x64.ActiveCfg = Release|Any CPU
137+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x64.Build.0 = Release|Any CPU
138+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x86.ActiveCfg = Release|Any CPU
139+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x86.Build.0 = Release|Any CPU
140+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
141+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
142+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x64.ActiveCfg = Debug|Any CPU
143+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x64.Build.0 = Debug|Any CPU
144+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x86.ActiveCfg = Debug|Any CPU
145+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x86.Build.0 = Debug|Any CPU
146+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
147+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|Any CPU.Build.0 = Release|Any CPU
148+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x64.ActiveCfg = Release|Any CPU
149+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x64.Build.0 = Release|Any CPU
150+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x86.ActiveCfg = Release|Any CPU
151+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x86.Build.0 = Release|Any CPU
152+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
153+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|Any CPU.Build.0 = Debug|Any CPU
154+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x64.ActiveCfg = Debug|Any CPU
155+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x64.Build.0 = Debug|Any CPU
156+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x86.ActiveCfg = Debug|Any CPU
157+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x86.Build.0 = Debug|Any CPU
158+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|Any CPU.ActiveCfg = Release|Any CPU
159+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|Any CPU.Build.0 = Release|Any CPU
160+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x64.ActiveCfg = Release|Any CPU
161+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x64.Build.0 = Release|Any CPU
162+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x86.ActiveCfg = Release|Any CPU
163+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x86.Build.0 = Release|Any CPU
164+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
165+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
166+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x64.ActiveCfg = Debug|Any CPU
167+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x64.Build.0 = Debug|Any CPU
168+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x86.ActiveCfg = Debug|Any CPU
169+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x86.Build.0 = Debug|Any CPU
170+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
171+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|Any CPU.Build.0 = Release|Any CPU
172+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x64.ActiveCfg = Release|Any CPU
173+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x64.Build.0 = Release|Any CPU
174+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x86.ActiveCfg = Release|Any CPU
175+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x86.Build.0 = Release|Any CPU
120176
EndGlobalSection
121177
GlobalSection(SolutionProperties) = preSolution
122178
HideSolutionNode = FALSE
@@ -129,5 +185,9 @@ Global
129185
{A1B2C3D4-E5F6-7890-1234-567890ABCDEF} = {10E2E10B-AB65-4800-93ED-CDB737917154}
130186
{0F803EA9-AD74-46E3-9404-5D536E0F262C} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336}
131187
{31971414-5519-4149-B5C0-C88454EBE7AD} = {10E2E10B-AB65-4800-93ED-CDB737917154}
188+
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336}
189+
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0} = {10E2E10B-AB65-4800-93ED-CDB737917154}
190+
{9601A06C-4C43-4E68-8576-F92BBFFEA73B} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336}
191+
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5} = {10E2E10B-AB65-4800-93ED-CDB737917154}
132192
EndGlobalSection
133193
EndGlobal

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ IRootNode (vfs://)
6060
└── IFileNode (contains Content string)
6161
```
6262

63+
### Storage Providers
64+
External backends are plugged in behind a provider abstraction (the `Core` VFS itself stays storage-agnostic):
65+
- `Atypical.VirtualFileSystem.Providers.Abstractions` defines `IStorageProvider` (`ImportAsync`/`ReadFileAsync`/`WriteChangesAsync`/`GetAccountInfoAsync`), `IStorageProviderAuth`, `ProviderCapabilities`, and neutral records (`ProviderLoadOptions/Result`, `ProviderFileChange`, `CommitContext`, `ProviderFileMetadata`, `AuthRequest/Result`).
66+
- Branch/PR/fork are NOT base-interface methods — they are `ProviderCapabilities` flags plus `CommitContext` fields, so capable providers (GitHub) honor them and others (FTP) ignore them.
67+
- Implementations: `Atypical.VirtualFileSystem.GitHub` (`GitHubStorageProvider`, wraps the existing Octokit loader/write service via adapters — Token auth, supports branches/PR/fork) and `Atypical.VirtualFileSystem.Ftp` (`FtpStorageProvider` over FluentFTP — Credentials auth, read+write/overwrite, no branches/PR).
68+
- The Blazor app selects the active provider via `IStorageProviderRegistry` and drives its UI (import dialog fields, "submit changes") from `ProviderCapabilities`; FTP credentials and the GitHub PAT are persisted encrypted via `ProtectedLocalStorage`.
69+
6370
### Path System
6471
- `VFSRootPath` - Root directory (vfs://)
6572
- `VFSDirectoryPath` - Directory paths with case-insensitive comparison

docs/superpowers/plans/2026-06-02-storage-providers-foundation-ftp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -740,7 +740,7 @@ public sealed class GitHubStorageProvider : IStorageProvider
740740

741741
public async Task<ProviderLoadResult> ImportAsync(IVirtualFileSystem vfs, ProviderLoadOptions options, CancellationToken cancellationToken = default)
742742
{
743-
// RemoteRoot for GitHub is "owner/repo[/subpath]"; parse owner/repo and pass subpath via TargetPath.
743+
// RemoteRoot for GitHub is "owner/repo[/subpath]"; parse owner/repo and pass subpath via the loader's SubPath (the remote filter), NOT TargetPath (the VFS destination).
744744
var (owner, repo, subPath) = ParseRemoteRoot(options.RemoteRoot);
745745
var ghOptions = GitHubProviderAdapters.ToGitHubLoaderOptions(options with { RemoteRoot = subPath }, _auth.Token);
746746
var result = await _loader.LoadRepositoryAsync(vfs, owner, repo, ghOptions, cancellationToken);

src/Atypical.VirtualFileSystem.DemoBlazorApp/Atypical.VirtualFileSystem.DemoBlazorApp.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
<ItemGroup>
1414
<ProjectReference Include="../Atypical.VirtualFileSystem.Core/Atypical.VirtualFileSystem.Core.csproj" />
1515
<ProjectReference Include="../Atypical.VirtualFileSystem.GitHub/Atypical.VirtualFileSystem.GitHub.csproj" />
16+
<ProjectReference Include="../Atypical.VirtualFileSystem.Providers.Abstractions/Atypical.VirtualFileSystem.Providers.Abstractions.csproj" />
17+
<ProjectReference Include="../Atypical.VirtualFileSystem.Ftp/Atypical.VirtualFileSystem.Ftp.csproj" />
1618
</ItemGroup>
1719

1820
<!-- Tailwind CSS Build -->

src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Dialogs/CreatePullRequestDialog.razor

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
@* Create Pull Request Dialog *@
1+
@* Capability-driven "Submit changes" dialog (PR for GitHub, upload for FTP) *@
22
@inject GitHubPendingChangesService PendingChangesService
33
@inject GitHubAuthService AuthService
44
@inject ToastService Toast
5+
@inject StoragePendingChangesService StorageSubmit
6+
@inject IStorageProviderRegistry Registry
57
@implements IDisposable
68

7-
<Modal @bind-IsOpen="IsOpen" Title="Create Pull Request" Size="xl" CloseOnBackdropClick="@(!_isCreating)">
9+
<Modal @bind-IsOpen="IsOpen" Title="@DialogTitle" Size="xl" CloseOnBackdropClick="@(!_isCreating)">
810
<ChildContent>
911
@if (_result is not null && _result.Success)
1012
{
@@ -93,6 +95,51 @@
9395
</div>
9496
</div>
9597
}
98+
else if (!SupportsPullRequests)
99+
{
100+
@* Non-PR provider (e.g. FTP): simple upload confirmation *@
101+
<div class="space-y-4">
102+
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
103+
<div class="flex gap-2">
104+
<svg class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
105+
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
106+
</svg>
107+
<span class="text-sm text-blue-700">
108+
@Registry.Active.DisplayName uploads changes directly to the remoteno pull request is created.
109+
</span>
110+
</div>
111+
</div>
112+
113+
@if (_uploadChanges.Count == 0)
114+
{
115+
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
116+
<div class="flex gap-2">
117+
<svg class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
118+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
119+
</svg>
120+
<span class="text-sm text-amber-700">
121+
No tracked changes are available to upload for this provider yet.
122+
</span>
123+
</div>
124+
</div>
125+
}
126+
else
127+
{
128+
<label class="block text-sm font-medium text-gray-700 mb-2">
129+
Files to upload (@_uploadChanges.Count)
130+
</label>
131+
<div class="border border-gray-200 rounded-lg max-h-48 overflow-y-auto">
132+
@foreach (var change in _uploadChanges)
133+
{
134+
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-100 last:border-b-0 text-sm">
135+
<span class="text-gray-700 truncate flex-1" title="@change.RemotePath">@change.RemotePath</span>
136+
<span class="text-xs text-gray-400">@change.Kind</span>
137+
</div>
138+
}
139+
</div>
140+
}
141+
</div>
142+
}
96143
else
97144
{
98145
@* Input Form *@
@@ -223,6 +270,16 @@
223270
{
224271
<span class="text-sm text-gray-500">Creating pull request...</span>
225272
}
273+
else if (!SupportsPullRequests)
274+
{
275+
<Button Variant="ghost" OnClick="Close">Cancel</Button>
276+
<Button Variant="primary"
277+
OnClick="UploadChanges"
278+
Disabled="@(_uploadChanges.Count == 0)"
279+
Icon="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5">
280+
Upload changes
281+
</Button>
282+
}
226283
else
227284
{
228285
<Button Variant="ghost" OnClick="Close">Cancel</Button>
@@ -255,6 +312,15 @@
255312
private (string Owner, string Repository)? _repositoryInfo;
256313
private bool _wasOpen;
257314

315+
// Non-PR (upload) provider state.
316+
private List<ProviderFileChange> _uploadChanges = new();
317+
318+
// Capability-driven view selectors.
319+
private bool SupportsPullRequests => StorageSubmit.SupportsPullRequests;
320+
private string DialogTitle => SupportsPullRequests
321+
? "Create Pull Request"
322+
: $"Upload changes to {Registry.Active.DisplayName}";
323+
258324
private bool CanCreate => AuthService.IsAuthenticated
259325
&& !string.IsNullOrWhiteSpace(_title)
260326
&& !string.IsNullOrWhiteSpace(_branchName)
@@ -264,6 +330,7 @@
264330
{
265331
PendingChangesService.OnPendingChangesChanged += LoadChanges;
266332
AuthService.OnCredentialsChanged += OnCredentialsChanged;
333+
Registry.OnActiveProviderChanged += OnCredentialsChanged;
267334
}
268335

269336
private void OnCredentialsChanged() => InvokeAsync(StateHasChanged);
@@ -396,6 +463,41 @@
396463
}
397464
}
398465

466+
// Non-PR providers (e.g. FTP): upload the tracked changes directly via the active provider.
467+
// NOTE: FTP change-set tracking is not yet wired (see concern in the report); _uploadChanges
468+
// is empty today, so this path is reached only once tracking is implemented.
469+
private async Task UploadChanges()
470+
{
471+
if (_uploadChanges.Count == 0) return;
472+
473+
_isCreating = true;
474+
StateHasChanged();
475+
476+
try
477+
{
478+
var writeResult = await StorageSubmit.SubmitAsync(_uploadChanges, CommitContext.Empty);
479+
if (writeResult.Success)
480+
{
481+
Toast.ShowSuccess($"Uploaded {writeResult.FileResults.Count} file(s) successfully.");
482+
_uploadChanges = new();
483+
await Close();
484+
}
485+
else
486+
{
487+
Toast.ShowError(writeResult.Message ?? "One or more files failed to upload.");
488+
}
489+
}
490+
catch (Exception ex)
491+
{
492+
Toast.ShowError($"Upload failed: {ex.Message}");
493+
}
494+
finally
495+
{
496+
_isCreating = false;
497+
StateHasChanged();
498+
}
499+
}
500+
399501
private void ResetDialog()
400502
{
401503
_result = null;
@@ -470,5 +572,6 @@
470572
{
471573
PendingChangesService.OnPendingChangesChanged -= LoadChanges;
472574
AuthService.OnCredentialsChanged -= OnCredentialsChanged;
575+
Registry.OnActiveProviderChanged -= OnCredentialsChanged;
473576
}
474577
}

0 commit comments

Comments
 (0)