Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e86104d
feat(providers): scaffold Providers.Abstractions project
Jun 2, 2026
72eb341
feat(providers): add AuthKind, ProviderCapabilities, ProviderAccountInfo
Jun 2, 2026
e8bf2b0
feat(providers): add neutral load/read/write records
Jun 2, 2026
3982223
feat(providers): add IStorageProvider and IStorageProviderAuth
Jun 2, 2026
9868aba
test(providers): abstractions test project + capability tests
Jun 2, 2026
7ccca01
refactor(providers): add abstraction XML docs and broaden contract tests
Jun 2, 2026
070d1f3
build(github): reference Providers.Abstractions
Jun 2, 2026
5cc0632
feat(github): add GitHub<->neutral provider adapters
Jun 2, 2026
7857320
feat(github): add GitHubProviderAuth (Token)
Jun 2, 2026
7ff2378
feat(github): add GitHubStorageProvider wrapping the loader
Jun 2, 2026
0d9d322
fix(github): map ProviderLoadOptions.RemoteRoot to SubPath (remote fi…
Jun 2, 2026
b28400a
refactor(github): forward glob filter intent and fix result RemoteRoot
Jun 2, 2026
51bcc53
feat(ftp): scaffold Ftp project with FluentFTP
Jun 2, 2026
fa427d6
feat(ftp): IFtpConnection seam + FluentFTP implementation
Jun 2, 2026
94932ec
feat(ftp): FtpStorageProvider import + FtpProviderAuth (TDD with fake)
Jun 2, 2026
210ee6a
feat(ftp): WriteChangesAsync overwrite/delete with per-file results
Jun 2, 2026
a5fb723
feat(ftp): AddVirtualFileSystemFtp DI registration
Jun 2, 2026
6bcbbc5
refactor(ftp): consistent auth guard, leak-safe disconnect, dir nodes…
Jun 2, 2026
0736337
feat(blazor): storage provider registry
Jun 2, 2026
47e9fd4
feat(blazor): register GitHub and FTP providers + registry
Jun 2, 2026
1a56b62
feat(blazor): neutral StorageImportService + encrypted credential store
Jun 2, 2026
eb46f26
refactor(blazor): guard concurrent imports and align registry event n…
Jun 2, 2026
c8c4056
feat(blazor): capability-driven import dialog with provider picker
Jun 2, 2026
04af402
feat(blazor): capability-driven submit-changes (PR vs upload)
Jun 2, 2026
3f604cc
refactor(blazor): guard non-PR submit and align dispatcher marshalling
Jun 2, 2026
fa06f57
docs: document storage provider abstraction and FTP provider
Jun 2, 2026
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
60 changes: 60 additions & 0 deletions Atypical.VirtualFileSystem.sln
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.
EndProject
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}"
EndProject
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}"
EndProject
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}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Ftp", "src\Atypical.VirtualFileSystem.Ftp\Atypical.VirtualFileSystem.Ftp.csproj", "{9601A06C-4C43-4E68-8576-F92BBFFEA73B}"
EndProject
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}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -117,6 +125,54 @@ Global
{31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x64.Build.0 = Release|Any CPU
{31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x86.ActiveCfg = Release|Any CPU
{31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x86.Build.0 = Release|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x64.ActiveCfg = Debug|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x64.Build.0 = Debug|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x86.ActiveCfg = Debug|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x86.Build.0 = Debug|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|Any CPU.Build.0 = Release|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x64.ActiveCfg = Release|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x64.Build.0 = Release|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x86.ActiveCfg = Release|Any CPU
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x86.Build.0 = Release|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x64.ActiveCfg = Debug|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x64.Build.0 = Debug|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x86.ActiveCfg = Debug|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x86.Build.0 = Debug|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|Any CPU.Build.0 = Release|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x64.ActiveCfg = Release|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x64.Build.0 = Release|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x86.ActiveCfg = Release|Any CPU
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x86.Build.0 = Release|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x64.ActiveCfg = Debug|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x64.Build.0 = Debug|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x86.ActiveCfg = Debug|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x86.Build.0 = Debug|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|Any CPU.Build.0 = Release|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x64.ActiveCfg = Release|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x64.Build.0 = Release|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x86.ActiveCfg = Release|Any CPU
{9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x86.Build.0 = Release|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x64.ActiveCfg = Debug|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x64.Build.0 = Debug|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x86.ActiveCfg = Debug|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x86.Build.0 = Debug|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|Any CPU.Build.0 = Release|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x64.ActiveCfg = Release|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x64.Build.0 = Release|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x86.ActiveCfg = Release|Any CPU
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -129,5 +185,9 @@ Global
{A1B2C3D4-E5F6-7890-1234-567890ABCDEF} = {10E2E10B-AB65-4800-93ED-CDB737917154}
{0F803EA9-AD74-46E3-9404-5D536E0F262C} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336}
{31971414-5519-4149-B5C0-C88454EBE7AD} = {10E2E10B-AB65-4800-93ED-CDB737917154}
{B2E9D21C-6C01-4F28-AF7C-2EF16148E964} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336}
{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0} = {10E2E10B-AB65-4800-93ED-CDB737917154}
{9601A06C-4C43-4E68-8576-F92BBFFEA73B} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336}
{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5} = {10E2E10B-AB65-4800-93ED-CDB737917154}
EndGlobalSection
EndGlobal
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ IRootNode (vfs://)
└── IFileNode (contains Content string)
```

### Storage Providers
External backends are plugged in behind a provider abstraction (the `Core` VFS itself stays storage-agnostic):
- `Atypical.VirtualFileSystem.Providers.Abstractions` defines `IStorageProvider` (`ImportAsync`/`ReadFileAsync`/`WriteChangesAsync`/`GetAccountInfoAsync`), `IStorageProviderAuth`, `ProviderCapabilities`, and neutral records (`ProviderLoadOptions/Result`, `ProviderFileChange`, `CommitContext`, `ProviderFileMetadata`, `AuthRequest/Result`).
- 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.
- 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).
- 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`.

### Path System
- `VFSRootPath` - Root directory (vfs://)
- `VFSDirectoryPath` - Directory paths with case-insensitive comparison
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ public sealed class GitHubStorageProvider : IStorageProvider

public async Task<ProviderLoadResult> ImportAsync(IVirtualFileSystem vfs, ProviderLoadOptions options, CancellationToken cancellationToken = default)
{
// RemoteRoot for GitHub is "owner/repo[/subpath]"; parse owner/repo and pass subpath via TargetPath.
// 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).
var (owner, repo, subPath) = ParseRemoteRoot(options.RemoteRoot);
var ghOptions = GitHubProviderAdapters.ToGitHubLoaderOptions(options with { RemoteRoot = subPath }, _auth.Token);
var result = await _loader.LoadRepositoryAsync(vfs, owner, repo, ghOptions, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<ItemGroup>
<ProjectReference Include="../Atypical.VirtualFileSystem.Core/Atypical.VirtualFileSystem.Core.csproj" />
<ProjectReference Include="../Atypical.VirtualFileSystem.GitHub/Atypical.VirtualFileSystem.GitHub.csproj" />
<ProjectReference Include="../Atypical.VirtualFileSystem.Providers.Abstractions/Atypical.VirtualFileSystem.Providers.Abstractions.csproj" />
<ProjectReference Include="../Atypical.VirtualFileSystem.Ftp/Atypical.VirtualFileSystem.Ftp.csproj" />
</ItemGroup>

<!-- Tailwind CSS Build -->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
@* Create Pull Request Dialog *@
@* Capability-driven "Submit changes" dialog (PR for GitHub, upload for FTP) *@
@inject GitHubPendingChangesService PendingChangesService
@inject GitHubAuthService AuthService
@inject ToastService Toast
@inject StoragePendingChangesService StorageSubmit
@inject IStorageProviderRegistry Registry
@implements IDisposable

<Modal @bind-IsOpen="IsOpen" Title="Create Pull Request" Size="xl" CloseOnBackdropClick="@(!_isCreating)">
<Modal @bind-IsOpen="IsOpen" Title="@DialogTitle" Size="xl" CloseOnBackdropClick="@(!_isCreating)">
<ChildContent>
@if (_result is not null && _result.Success)
{
Expand Down Expand Up @@ -93,6 +95,51 @@
</div>
</div>
}
else if (!SupportsPullRequests)
{
@* Non-PR provider (e.g. FTP): simple upload confirmation *@
<div class="space-y-4">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex gap-2">
<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">
<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" />
</svg>
<span class="text-sm text-blue-700">
@Registry.Active.DisplayName uploads changes directly to the remote — no pull request is created.
</span>
</div>
</div>

@if (_uploadChanges.Count == 0)
{
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div class="flex gap-2">
<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">
<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" />
</svg>
<span class="text-sm text-amber-700">
No tracked changes are available to upload for this provider yet.
</span>
</div>
</div>
}
else
{
<label class="block text-sm font-medium text-gray-700 mb-2">
Files to upload (@_uploadChanges.Count)
</label>
<div class="border border-gray-200 rounded-lg max-h-48 overflow-y-auto">
@foreach (var change in _uploadChanges)
{
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-100 last:border-b-0 text-sm">
<span class="text-gray-700 truncate flex-1" title="@change.RemotePath">@change.RemotePath</span>
<span class="text-xs text-gray-400">@change.Kind</span>
</div>
}
</div>
}
</div>
}
else
{
@* Input Form *@
Expand Down Expand Up @@ -223,6 +270,16 @@
{
<span class="text-sm text-gray-500">Creating pull request...</span>
}
else if (!SupportsPullRequests)
{
<Button Variant="ghost" OnClick="Close">Cancel</Button>
<Button Variant="primary"
OnClick="UploadChanges"
Disabled="@(_uploadChanges.Count == 0)"
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">
Upload changes
</Button>
}
else
{
<Button Variant="ghost" OnClick="Close">Cancel</Button>
Expand Down Expand Up @@ -255,6 +312,15 @@
private (string Owner, string Repository)? _repositoryInfo;
private bool _wasOpen;

// Non-PR (upload) provider state.
private List<ProviderFileChange> _uploadChanges = new();

// Capability-driven view selectors.
private bool SupportsPullRequests => StorageSubmit.SupportsPullRequests;
private string DialogTitle => SupportsPullRequests
? "Create Pull Request"
: $"Upload changes to {Registry.Active.DisplayName}";

private bool CanCreate => AuthService.IsAuthenticated
&& !string.IsNullOrWhiteSpace(_title)
&& !string.IsNullOrWhiteSpace(_branchName)
Expand All @@ -264,6 +330,7 @@
{
PendingChangesService.OnPendingChangesChanged += LoadChanges;
AuthService.OnCredentialsChanged += OnCredentialsChanged;
Registry.OnActiveProviderChanged += OnCredentialsChanged;
}

private void OnCredentialsChanged() => InvokeAsync(StateHasChanged);
Expand Down Expand Up @@ -396,6 +463,41 @@
}
}

// Non-PR providers (e.g. FTP): upload the tracked changes directly via the active provider.
// NOTE: FTP change-set tracking is not yet wired (see concern in the report); _uploadChanges
// is empty today, so this path is reached only once tracking is implemented.
private async Task UploadChanges()
{
if (_uploadChanges.Count == 0) return;

_isCreating = true;
StateHasChanged();

try
{
var writeResult = await StorageSubmit.SubmitAsync(_uploadChanges, CommitContext.Empty);
if (writeResult.Success)
{
Toast.ShowSuccess($"Uploaded {writeResult.FileResults.Count} file(s) successfully.");
_uploadChanges = new();
await Close();
}
else
{
Toast.ShowError(writeResult.Message ?? "One or more files failed to upload.");
}
}
catch (Exception ex)
{
Toast.ShowError($"Upload failed: {ex.Message}");
}
finally
{
_isCreating = false;
StateHasChanged();
}
}

private void ResetDialog()
{
_result = null;
Expand Down Expand Up @@ -470,5 +572,6 @@
{
PendingChangesService.OnPendingChangesChanged -= LoadChanges;
AuthService.OnCredentialsChanged -= OnCredentialsChanged;
Registry.OnActiveProviderChanged -= OnCredentialsChanged;
}
}
Loading
Loading