Patches 1 active
Initial accepted
admin · 168 files · accepted Apr 06
Initial
admin · accepted Apr 06 · base: trunk-head
168 files changed+11454 -0 lines
Diff
Files (168)
Review (0)
Overlaps (0)
+ .claude/settings.local.json
1{
2 "permissions": {
3 "allow": [
4 "Bash(dotnet build:*)",
5 "Bash(dotnet test:*)"
6 ]
7 }
8}
9
+ .dockerignore
1**/bin/
2**/obj/
3**/.vs/
4**/node_modules/
5src - Kopie/
6tests/
7docs/
8*.md
9.git/
10.dockerignore
11Dockerfile
12
+ Catena.slnx
1<Solution>
2 <Folder Name="/src/">
3 <Project Path="src/Catena.Client/Catena.Client.csproj" />
4 <Project Path="src/Catena.Server/Catena.Server.csproj" />
5 <Project Path="src/Catena.Shared/Catena.Shared.csproj" />
6 <Project Path="src/Catena.Storage/Catena.Storage.csproj" />
7 <Project Path="src/Catena.Web/Catena.Web.csproj" />
8 </Folder>
9 <Folder Name="/tests/">
10 <Project Path="tests/Catena.Tests/Catena.Tests.csproj" />
11 </Folder>
12</Solution>
13
+ Dockerfile
1FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
2WORKDIR /src
3
4COPY src/Catena.Shared/Catena.Shared.csproj src/Catena.Shared/
5COPY src/Catena.Storage/Catena.Storage.csproj src/Catena.Storage/
6COPY src/Catena.Web/Catena.Web.csproj src/Catena.Web/
7COPY src/Catena.Server/Catena.Server.csproj src/Catena.Server/
8RUN dotnet restore src/Catena.Server/Catena.Server.csproj
9
10COPY src/ src/
11RUN dotnet publish src/Catena.Server/Catena.Server.csproj -c Release -o /app/publish --no-restore
12
13FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
14WORKDIR /app
15COPY --from=build /app/publish .
16
17ENV ASPNETCORE_URLS=http://+:8080
18ENV Catena__DataDir=/data
19
20EXPOSE 8080
21VOLUME /data
22
23ENTRYPOINT ["dotnet", "Catena.Server.dll"]
24
+ docker-build-push.cmd
1@echo off
2setlocal
3
4set REGISTRY=docker.timelane.cloud
5set IMAGE=catena
6
7:: Date-based version: yyyy.MM.dd.BUILD
8for /f "tokens=1-3 delims=/" %%a in ("%date%") do (
9 set DAY=%%a
10 set MONTH=%%b
11 set YEAR=%%c
12)
13
14:: Fallback: use wmic for reliable date format
15for /f "tokens=2 delims==" %%i in ('wmic os get localdatetime /value') do set DT=%%i
16set YEAR=%DT:~0,4%
17set MONTH=%DT:~4,2%
18set DAY=%DT:~6,2%
19
20:: Build number: default 1, pass as argument to override
21set BUILD=%1
22if "%BUILD%"=="" set BUILD=1
23
24set TAG=%YEAR%.%MONTH%.%DAY%.%BUILD%
25
26echo Building %REGISTRY%/%IMAGE%:%TAG% ...
27docker build -t %REGISTRY%/%IMAGE%:%TAG% -t %REGISTRY%/%IMAGE%:latest .
28
29if %errorlevel% neq 0 (
30 echo Build failed!
31 exit /b 1
32)
33
34echo Pushing %REGISTRY%/%IMAGE%:%TAG% ...
35docker push %REGISTRY%/%IMAGE%:%TAG%
36docker push %REGISTRY%/%IMAGE%:latest
37
38if %errorlevel% neq 0 (
39 echo Push failed!
40 exit /b 1
41)
42
43echo Done: %REGISTRY%/%IMAGE%:%TAG%
44
+ docker-compose.yml
1services:
2 catena:
3 image: docker.timelane.cloud/catena:latest
4 container_name: catena
5 ports:
6 - "18999:8080"
7 volumes:
8 - catena-data:/data
9 environment:
10 - Catena__DataDir=/data
11 - Catena__AuthEnabled=true
12 restart: unless-stopped
13
14volumes:
15 catena-data:
16
+ src/Catena.Client/Catena.Client.csproj
1<Project Sdk="Microsoft.NET.Sdk">
2
3 <ItemGroup>
4 <ProjectReference Include="..\Catena.Shared\Catena.Shared.csproj" />
5 </ItemGroup>
6
7 <PropertyGroup>
8 <OutputType>Exe</OutputType>
9 <TargetFramework>net10.0</TargetFramework>
10 <ImplicitUsings>enable</ImplicitUsings>
11 <Nullable>enable</Nullable>
12 <AssemblyName>catena</AssemblyName>
13 <PublishAot>true</PublishAot>
14 <DebuggerSupport>false</DebuggerSupport>
15 <OptimizationPreference>Size</OptimizationPreference>
16 <InvariantGlobalization>true</InvariantGlobalization>
17 <StackTraceSupport>false</StackTraceSupport>
18 <UseSystemResourceKeys>true</UseSystemResourceKeys>
19 </PropertyGroup>
20
21</Project>
22
+ src/Catena.Client/CatenaClient.cs
1using System.Net.Http.Json;
2using System.Text.Json;
3using Catena.Shared.Dtos;
4using Catena.Shared.Models;
5
6namespace Catena.Client;
7
8public sealed class CatenaClient(HttpClient http)
9{
10 private static CatenaJsonContext Json => CatenaJsonContext.Default;
11
12 public async Task<ProjectResponse> CreateProjectAsync(string name, bool isPublic = false)
13 {
14 var response = await http.PostAsJsonAsync("/projects", new CreateProjectRequest(name, isPublic), Json.CreateProjectRequest);
15 response.EnsureSuccessStatusCode();
16 return (await response.Content.ReadFromJsonAsync(Json.ProjectResponse))!;
17 }
18
19 public async Task<ProjectResponse?> GetProjectAsync(string id)
20 {
21 return await http.GetFromJsonAsync($"/projects/{id}", Json.ProjectResponse);
22 }
23
24 public async Task<TrunkStateResponse> GetTrunkStateAsync(string projectId)
25 {
26 return (await http.GetFromJsonAsync($"/projects/{projectId}/trunk", Json.TrunkStateResponse))!;
27 }
28
29 public async Task<PatchResponse> CreatePatchAsync(string projectId, CreatePatchRequest request)
30 {
31 var response = await http.PostAsJsonAsync($"/projects/{projectId}/patches", request, Json.CreatePatchRequest);
32 response.EnsureSuccessStatusCode();
33 return (await response.Content.ReadFromJsonAsync(Json.PatchResponse))!;
34 }
35
36 public async Task<List<PatchResponse>> ListPatchesAsync(string projectId, Maturity? maturity = null, string? author = null)
37 {
38 var query = $"/projects/{projectId}/patches";
39 var parameters = new List<string>();
40 if (maturity.HasValue) parameters.Add($"maturity={maturity.Value}");
41 if (author is not null) parameters.Add($"author={author}");
42 if (parameters.Count > 0) query += "?" + string.Join("&", parameters);
43
44 return (await http.GetFromJsonAsync(query, Json.ListPatchResponse))!;
45 }
46
47 public async Task<(PatchResponse? Patch, string? Error)> UpdateMaturityAsync(string projectId, string patchId, Maturity maturity)
48 {
49 var response = await http.PutAsJsonAsync(
50 $"/projects/{projectId}/patches/{patchId}/maturity",
51 new UpdateMaturityRequest(maturity), Json.UpdateMaturityRequest);
52
53 if (response.IsSuccessStatusCode)
54 {
55 var patch = await response.Content.ReadFromJsonAsync(Json.PatchResponse);
56 return (patch, null);
57 }
58
59 var error = await response.Content.ReadAsStringAsync();
60 return (null, error);
61 }
62
63 public async Task<List<PatchResponse>> GetHistoryAsync(string projectId, string? file = null, string? author = null)
64 {
65 var query = $"/projects/{projectId}/trunk/history";
66 var parameters = new List<string>();
67 if (file is not null) parameters.Add($"file={Uri.EscapeDataString(file)}");
68 if (author is not null) parameters.Add($"author={Uri.EscapeDataString(author)}");
69 if (parameters.Count > 0) query += "?" + string.Join("&", parameters);
70
71 return (await http.GetFromJsonAsync(query, Json.ListPatchResponse))!;
72 }
73
74 public async Task<PatchDiffResponse?> GetDiffAsync(string projectId, string patchId)
75 {
76 return await http.GetFromJsonAsync($"/projects/{projectId}/patches/{patchId}/diff", Json.PatchDiffResponse);
77 }
78
79 public async Task<RevertResponse> RevertPatchAsync(string projectId, string patchId)
80 {
81 var response = await http.PostAsync($"/projects/{projectId}/patches/{patchId}/revert", null);
82 response.EnsureSuccessStatusCode();
83 return (await response.Content.ReadFromJsonAsync(Json.RevertResponse))!;
84 }
85
86 public async Task<ReleaseResponse> CreateReleaseAsync(string projectId, string version)
87 {
88 var response = await http.PostAsJsonAsync($"/projects/{projectId}/releases", new CreateReleaseRequest(version), Json.CreateReleaseRequest);
89 response.EnsureSuccessStatusCode();
90 return (await response.Content.ReadFromJsonAsync(Json.ReleaseResponse))!;
91 }
92
93 public async Task<List<ReleaseResponse>> ListReleasesAsync(string projectId)
94 {
95 return (await http.GetFromJsonAsync($"/projects/{projectId}/releases", Json.ListReleaseResponse))!;
96 }
97
98 public async Task<PatchDiffResponse?> GetPatchDiffAsync(string projectId, string patchId)
99 {
100 return await http.GetFromJsonAsync($"/projects/{projectId}/patches/{patchId}/diff", Json.PatchDiffResponse);
101 }
102
103 public async IAsyncEnumerable<FileContentResponse> DownloadFilesAsync(string projectId, List<string> files)
104 {
105 var response = await http.PostAsJsonAsync($"/projects/{projectId}/trunk/download", new BulkDownloadRequest(files), Json.BulkDownloadRequest);
106 response.EnsureSuccessStatusCode();
107
108 using var stream = await response.Content.ReadAsStreamAsync();
109 using var reader = new StreamReader(stream);
110
111 while (await reader.ReadLineAsync() is { } line)
112 {
113 if (string.IsNullOrWhiteSpace(line)) continue;
114 var entry = JsonSerializer.Deserialize(line, Json.FileContentResponse);
115 if (entry is not null)
116 yield return entry;
117 }
118 }
119
120 public async Task<byte[]?> DownloadPatchBlobAsync(string projectId, string patchId, int opIndex)
121 {
122 var response = await http.GetAsync($"/projects/{projectId}/patches/{patchId}/blobs/{opIndex}");
123 if (!response.IsSuccessStatusCode) return null;
124 return await response.Content.ReadAsByteArrayAsync();
125 }
126}
127
+ src/Catena.Client/CatenaJsonContext.cs
1using System.Text.Json.Serialization;
2using Catena.Client.Config;
3using Catena.Shared.Dtos;
4
5namespace Catena.Client;
6
7[JsonSerializable(typeof(WorkspaceConfig))]
8[JsonSerializable(typeof(CreateProjectRequest))]
9[JsonSerializable(typeof(ProjectResponse))]
10[JsonSerializable(typeof(TrunkStateResponse))]
11[JsonSerializable(typeof(CreatePatchRequest))]
12[JsonSerializable(typeof(PatchResponse))]
13[JsonSerializable(typeof(List<PatchResponse>), TypeInfoPropertyName = "ListPatchResponse")]
14[JsonSerializable(typeof(UpdateMaturityRequest))]
15[JsonSerializable(typeof(PatchDiffResponse))]
16[JsonSerializable(typeof(RevertResponse))]
17[JsonSerializable(typeof(CreateReleaseRequest))]
18[JsonSerializable(typeof(ReleaseResponse))]
19[JsonSerializable(typeof(List<ReleaseResponse>), TypeInfoPropertyName = "ListReleaseResponse")]
20[JsonSerializable(typeof(BulkDownloadRequest))]
21[JsonSerializable(typeof(FileContentResponse))]
22[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)]
23internal sealed partial class CatenaJsonContext : JsonSerializerContext;
24
+ src/Catena.Client/Commands/AcceptCommand.cs
1using Catena.Shared.Models;
2
3namespace Catena.Client.Commands;
4
5public sealed class AcceptCommand(CatenaClient client, string projectId)
6{
7 public async Task<int> RunAsync(string? patchId)
8 {
9 if (patchId is null)
10 {
11 Console.Error.WriteLine("Patch ID is required. Usage: catena accept <id>");
12 return 1;
13 }
14
15 try
16 {
17 var (patch, error) = await client.UpdateMaturityAsync(projectId, patchId, Maturity.Accepted);
18 if (error is not null)
19 {
20 Console.Error.WriteLine($"Failed to accept: {error}");
21 return 1;
22 }
23
24 Console.WriteLine($"Patch {patch!.Id} accepted. Trunk updated.");
25 return 0;
26 }
27 catch (HttpRequestException ex)
28 {
29 Console.Error.WriteLine($"Cannot reach server: {ex.Message}");
30 return 1;
31 }
32 }
33}
34
+ src/Catena.Client/Commands/DiffCommand.cs
1using Catena.Shared.Models;
2
3namespace Catena.Client.Commands;
4
5public sealed class DiffCommand(CatenaClient client, string projectId)
6{
7 public async Task<int> RunAsync(string? patchId)
8 {
9 try
10 {
11 if (patchId is null)
12 {
13 Console.Error.WriteLine("Patch ID required. Usage: catena diff <id>");
14 return 1;
15 }
16
17 var diff = await client.GetDiffAsync(projectId, patchId);
18 if (diff is null)
19 {
20 Console.Error.WriteLine($"Patch {patchId} not found.");
21 return 1;
22 }
23
24 Console.WriteLine($"Patch {diff.PatchId} {diff.Timestamp:yyyy-MM-dd HH:mm} {diff.Author}");
25 Console.WriteLine($" {diff.Description}");
26 Console.WriteLine();
27
28 foreach (var entry in diff.Entries)
29 {
30 var prefix = entry.Type switch
31 {
32 OpType.Insert => "+",
33 OpType.Modify => "~",
34 OpType.Delete => "-",
35 OpType.Rename => ">",
36 OpType.Move => ">",
37 _ => "?"
38 };
39
40 var size = entry.Bytes > 0 ? $" ({FormatBytes(entry.Bytes)})" : "";
41 var target = entry.NewPath is not null ? $" → {entry.NewPath}" : "";
42
43 Console.WriteLine($" {prefix} {entry.File}{target}{size}");
44 }
45
46 return 0;
47 }
48 catch (HttpRequestException ex)
49 {
50 Console.Error.WriteLine($"Cannot reach server: {ex.Message}");
51 return 1;
52 }
53 }
54
55 private static string FormatBytes(long bytes) => bytes switch
56 {
57 < 1024 => $"{bytes} B",
58 < 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
59 _ => $"{bytes / (1024.0 * 1024.0):F1} MB"
60 };
61}
62
+ src/Catena.Client/Commands/DraftCommand.cs
1using Catena.Shared.Dtos;
2using Catena.Shared.Models;
3using Catena.Shared.Tracking;
4
5namespace Catena.Client.Commands;
6
7public sealed class DraftCommand(CatenaClient client, string projectId)
8{
9 public async Task<int> RunAsync(string workspacePath, bool sync, string? description,
10 List<string>? dependsOn = null, List<string>? targets = null)
11 {
12 Dictionary<string, string> trunkFiles;
13 string trunkHash;
14 try
15 {
16 var trunk = await client.GetTrunkStateAsync(projectId);
17 trunkFiles = trunk.Files;
18 trunkHash = trunk.Hash;
19 }
20 catch (HttpRequestException ex)
21 {
22 Console.Error.WriteLine($"Cannot reach server: {ex.Message}");
23 return 1;
24 }
25
26 var filter = await IgnoreFilter.LoadAsync(workspacePath);
27 var localFiles = await WorkspaceScanner.ScanAsync(workspacePath, filter);
28 var diff = WorkspaceDiff.Compute(localFiles, trunkFiles);
29
30 if (!diff.HasChanges)
31 {
32 Console.WriteLine("No changes to draft.");
33 return 0;
34 }
35
36 var ops = new List<FileOperation>();
37
38 foreach (var file in diff.Added)
39 {
40 var content = await File.ReadAllBytesAsync(Path.Combine(workspacePath, file));
41 ops.Add(new FileOperation
42 {
43 Type = OpType.Insert,
44 File = file,
45 ContentBase64 = Convert.ToBase64String(content)
46 });
47 }
48
49 foreach (var file in diff.Modified)
50 {
51 var content = await File.ReadAllBytesAsync(Path.Combine(workspacePath, file));
52 ops.Add(new FileOperation
53 {
54 Type = OpType.Modify,
55 File = file,
56 ContentBase64 = Convert.ToBase64String(content)
57 });
58 }
59
60 foreach (var file in diff.Deleted)
61 {
62 ops.Add(new FileOperation
63 {
64 Type = OpType.Delete,
65 File = file
66 });
67 }
68
69 var maturity = sync ? Maturity.DraftSynced : Maturity.DraftLocal;
70
71 var request = new CreatePatchRequest
72 {
73 Author = Environment.UserName,
74 Description = description ?? $"Draft: {diff.Added.Count} added, {diff.Modified.Count} modified, {diff.Deleted.Count} deleted",
75 Maturity = maturity,
76 Dependencies = dependsOn ?? [],
77 Targets = targets is { Count: > 0 } ? targets : ["trunk"],
78 BaseHash = trunkHash,
79 Ops = ops
80 };
81
82 try
83 {
84 var patch = await client.CreatePatchAsync(projectId, request);
85 Console.WriteLine($"Patch created: {patch.Id}");
86 Console.WriteLine($" {patch.OpCount} operations, maturity: {patch.Maturity}");
87 return 0;
88 }
89 catch (HttpRequestException ex)
90 {
91 Console.Error.WriteLine($"Failed to create patch: {ex.Message}");
92 return 1;
93 }
94 }
95}
96
+ src/Catena.Client/Commands/InitCommand.cs
1using Catena.Client.Config;
2
3namespace Catena.Client.Commands;
4
5public static class InitCommand
6{
7 public static async Task<int> RunAsync(string serverUrl, string? newProjectName, string? projectId, string workspacePath, string? apiKey = null, bool isPublic = false)
8 {
9 if (newProjectName is null && projectId is null)
10 {
11 Console.Error.WriteLine("Either --new <name> or --project <id> is required.");
12 return 1;
13 }
14
15 var http = new HttpClient { BaseAddress = new Uri(serverUrl) };
16 if (apiKey is not null)
17 http.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
18 var client = new CatenaClient(http);
19
20 string resolvedId;
21
22 try
23 {
24 if (newProjectName is not null)
25 {
26 var project = await client.CreateProjectAsync(newProjectName, isPublic);
27 resolvedId = project.Id;
28 Console.WriteLine($"Project created: {project.Name} ({project.Id})");
29 }
30 else
31 {
32 var project = await client.GetProjectAsync(projectId!);
33 if (project is null)
34 {
35 Console.Error.WriteLine($"Project {projectId} not found.");
36 return 1;
37 }
38 resolvedId = project.Id;
39 Console.WriteLine($"Connected to: {project.Name} ({project.Id})");
40 }
41 }
42 catch (HttpRequestException ex)
43 {
44 Console.Error.WriteLine($"Cannot reach server at {serverUrl}: {ex.Message}");
45 return 1;
46 }
47
48 var config = new WorkspaceConfig
49 {
50 ServerUrl = serverUrl,
51 ProjectId = resolvedId,
52 ApiKey = apiKey
53 };
54 await config.SaveAsync(workspacePath);
55 Console.WriteLine($"Workspace initialized in {Path.Combine(workspacePath, ".catena")}/");
56 return 0;
57 }
58}
59
+ src/Catena.Client/Commands/LogCommand.cs
1namespace Catena.Client.Commands;
2
3public sealed class LogCommand(CatenaClient client, string projectId)
4{
5 public async Task<int> RunAsync(string? file, string? author)
6 {
7 try
8 {
9 var history = await client.GetHistoryAsync(projectId, file, author);
10
11 if (history.Count == 0)
12 {
13 Console.WriteLine("No patches in history.");
14 return 0;
15 }
16
17 foreach (var patch in history)
18 {
19 Console.WriteLine($"{patch.Id} {patch.Timestamp:yyyy-MM-dd HH:mm} {patch.Author}");
20 Console.WriteLine($" {patch.Description} ({patch.OpCount} ops)");
21 Console.WriteLine();
22 }
23
24 return 0;
25 }
26 catch (HttpRequestException ex)
27 {
28 Console.Error.WriteLine($"Cannot reach server: {ex.Message}");
29 return 1;
30 }
31 }
32}
33
+ src/Catena.Client/Commands/ProposeCommand.cs
1using Catena.Shared.Models;
2
3namespace Catena.Client.Commands;
4
5public sealed class ProposeCommand(CatenaClient client, string projectId)
6{
7 public async Task<int> RunAsync(string? patchId)
8 {
9 try
10 {
11 if (patchId is null)
12 {
13 var patches = await client.ListPatchesAsync(projectId);
14 var draft = patches.FirstOrDefault(p =>
15 p.Maturity is Maturity.DraftSynced or Maturity.DraftShared);
16
17 if (draft is null)
18 {
19 Console.Error.WriteLine("No synced draft found to propose.");
20 return 1;
21 }
22
23 patchId = draft.Id;
24 }
25
26 var (patch, error) = await client.UpdateMaturityAsync(projectId, patchId, Maturity.Proposed);
27 if (error is not null)
28 {
29 Console.Error.WriteLine($"Failed to propose: {error}");
30 return 1;
31 }
32
33 Console.WriteLine($"Patch {patch!.Id} proposed.");
34 return 0;
35 }
36 catch (HttpRequestException ex)
37 {
38 Console.Error.WriteLine($"Cannot reach server: {ex.Message}");
39 return 1;
40 }
41 }
42}
43
+ src/Catena.Client/Commands/ReleaseCommand.cs
1namespace Catena.Client.Commands;
2
3public sealed class ReleaseCommand(CatenaClient client, string projectId)
4{
5 public async Task<int> RunAsync(string? version)
6 {
7 if (version is null)
8 {
9 Console.Error.WriteLine("Version required. Usage: catena release v1.0.0");
10 return 1;
11 }
12
13 try
14 {
15 var release = await client.CreateReleaseAsync(projectId, version);
16 Console.WriteLine($"Release {release.Version} created.");
17 Console.WriteLine($" {release.FileCount} files, trunk hash: {release.TrunkHash}");
18 return 0;
19 }
20 catch (HttpRequestException ex)
21 {
22 Console.Error.WriteLine($"Failed to create release: {ex.Message}");
23 return 1;
24 }
25 }
26}
27
+ src/Catena.Client/Commands/RevertCommand.cs
1namespace Catena.Client.Commands;
2
3public sealed class RevertCommand(CatenaClient client, string projectId)
4{
5 public async Task<int> RunAsync(string? patchId)
6 {
7 if (patchId is null)
8 {
9 Console.Error.WriteLine("Patch ID required. Usage: catena revert <id>");
10 return 1;
11 }
12
13 try
14 {
15 var result = await client.RevertPatchAsync(projectId, patchId);
16 Console.WriteLine($"Revert patch created: {result.RevertPatch.Id}");
17 Console.WriteLine($" Reverts: {result.OriginalPatchId}");
18 Console.WriteLine($" {result.RevertPatch.OpCount} ops, maturity: {result.RevertPatch.Maturity}");
19 Console.WriteLine(" Use 'catena propose' and 'catena accept' to apply.");
20 return 0;
21 }
22 catch (HttpRequestException ex)
23 {
24 Console.Error.WriteLine($"Failed to revert: {ex.Message}");
25 return 1;
26 }
27 }
28}
29
+ src/Catena.Client/Commands/StatusCommand.cs
1using Catena.Shared.Tracking;
2
3namespace Catena.Client.Commands;
4
5public sealed class StatusCommand(CatenaClient client, string projectId)
6{
7 public async Task<int> RunAsync(string workspacePath)
8 {
9 Dictionary<string, string> trunkFiles;
10 try
11 {
12 var trunk = await client.GetTrunkStateAsync(projectId);
13 trunkFiles = trunk.Files;
14 }
15 catch (HttpRequestException ex)
16 {
17 Console.Error.WriteLine($"Cannot reach server: {ex.Message}");
18 return 1;
19 }
20
21 var filter = await IgnoreFilter.LoadAsync(workspacePath);
22 var localFiles = await WorkspaceScanner.ScanAsync(workspacePath, filter);
23 var diff = WorkspaceDiff.Compute(localFiles, trunkFiles);
24
25 if (!diff.HasChanges)
26 {
27 Console.WriteLine("Clean. No changes.");
28 return 0;
29 }
30
31 foreach (var file in diff.Added)
32 Console.WriteLine($" + {file}");
33 foreach (var file in diff.Modified)
34 Console.WriteLine($" ~ {file}");
35 foreach (var file in diff.Deleted)
36 Console.WriteLine($" - {file}");
37
38 Console.WriteLine();
39 Console.WriteLine($"{diff.Added.Count} added, {diff.Modified.Count} modified, {diff.Deleted.Count} deleted");
40 return 0;
41 }
42}
43
+ src/Catena.Client/Commands/SyncCommand.cs
1using Catena.Shared.Dtos;
2using Catena.Shared.Models;
3using Catena.Shared.Tracking;
4
5namespace Catena.Client.Commands;
6
7public sealed class SyncCommand(CatenaClient client, string projectId)
8{
9 public async Task<int> RunAsync(string workspacePath, string direction, string? patchId,
10 string? description, bool explicitDown)
11 {
12 if (direction == "up")
13 return await SyncUp(workspacePath, description);
14 else
15 return await SyncDown(workspacePath, patchId, explicitDown);
16 }
17
18 private async Task<int> SyncDown(string workspacePath, string? patchId, bool explicitDown)
19 {
20 try
21 {
22 // Check for local changes first
23 var trunk = await client.GetTrunkStateAsync(projectId);
24 var targetFiles = new Dictionary<string, string>(trunk.Files);
25
26 Dictionary<string, (string PatchId, int OpIndex)>? patchBlobs = null;
27 if (patchId is not null)
28 {
29 var diff = await client.GetDiffAsync(projectId, patchId);
30 if (diff is null)
31 {
32 Console.Error.WriteLine($"Patch {patchId} not found.");
33 return 1;
34 }
35
36 patchBlobs = new();
37 for (int i = 0; i < diff.Entries.Count; i++)
38 {
39 var entry = diff.Entries[i];
40 switch (entry.Type)
41 {
42 case OpType.Insert:
43 case OpType.Modify:
44 targetFiles[entry.File] = $"patch:{patchId}:{i}";
45 patchBlobs[entry.File] = (patchId, i);
46 break;
47 case OpType.Delete:
48 targetFiles.Remove(entry.File);
49 break;
50 case OpType.Rename:
51 case OpType.Move:
52 if (entry.NewPath is not null && targetFiles.Remove(entry.File, out var hash))
53 targetFiles[entry.NewPath] = hash;
54 break;
55 }
56 }
57 }
58
59 var filter = await IgnoreFilter.LoadAsync(workspacePath);
60 var localFiles = await WorkspaceScanner.ScanAsync(workspacePath, filter);
61
62 var toDownload = new List<string>();
63 var toDownloadFromPatch = new List<(string File, string PatchId, int OpIndex)>();
64 var toDelete = new List<string>();
65
66 foreach (var (path, hash) in targetFiles)
67 {
68 if (!localFiles.TryGetValue(path, out var localHash) || localHash != hash)
69 {
70 if (patchBlobs is not null && patchBlobs.TryGetValue(path, out var blobRef))
71 toDownloadFromPatch.Add((path, blobRef.PatchId, blobRef.OpIndex));
72 else
73 toDownload.Add(path);
74 }
75 }
76
77 foreach (var path in localFiles.Keys)
78 {
79 if (!targetFiles.ContainsKey(path))
80 toDelete.Add(path);
81 }
82
83 if (toDownload.Count == 0 && toDownloadFromPatch.Count == 0 && toDelete.Count == 0)
84 {
85 Console.WriteLine("Already up to date.");
86 return 0;
87 }
88
89 // If direction was implicit (just `catena sync`), warn and confirm
90 if (!explicitDown)
91 {
92 var localChanges = WorkspaceDiff.Compute(localFiles, trunk.Files);
93 if (localChanges.HasChanges)
94 {
95 Console.WriteLine($"Warning: you have local changes ({localChanges.Added.Count} added, {localChanges.Modified.Count} modified, {localChanges.Deleted.Count} deleted).");
96 Console.WriteLine("Sync down will overwrite them. Use 'catena sync up' to push first.");
97 Console.Write("Continue? [y/N] ");
98 var answer = Console.ReadLine()?.Trim().ToLower();
99 if (answer is not "y" and not "yes")
100 {
101 Console.WriteLine("Aborted.");
102 return 0;
103 }
104 }
105 }
106
107 // Download
108 if (toDownload.Count > 0)
109 {
110 Console.WriteLine($"Downloading {toDownload.Count} files from trunk...");
111 await foreach (var file in client.DownloadFilesAsync(projectId, toDownload))
112 {
113 var fullPath = Path.Combine(workspacePath, file.Path.Replace('/', Path.DirectorySeparatorChar));
114 Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
115 var content = Convert.FromBase64String(file.ContentBase64);
116 await File.WriteAllBytesAsync(fullPath, content);
117 }
118 }
119
120 foreach (var (file, pid, opIndex) in toDownloadFromPatch)
121 {
122 Console.WriteLine($" + {file} (from patch)");
123 var blob = await client.DownloadPatchBlobAsync(projectId, pid, opIndex);
124 if (blob is null) continue;
125 var fullPath = Path.Combine(workspacePath, file.Replace('/', Path.DirectorySeparatorChar));
126 Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
127 await File.WriteAllBytesAsync(fullPath, blob);
128 }
129
130 foreach (var path in toDelete)
131 {
132 var fullPath = Path.Combine(workspacePath, path.Replace('/', Path.DirectorySeparatorChar));
133 if (File.Exists(fullPath))
134 {
135 File.Delete(fullPath);
136 Console.WriteLine($" - {path}");
137 }
138 }
139
140 var total = toDownload.Count + toDownloadFromPatch.Count;
141 Console.WriteLine($"Synced down: {total} downloaded, {toDelete.Count} deleted.");
142 return 0;
143 }
144 catch (HttpRequestException ex)
145 {
146 Console.Error.WriteLine($"Cannot reach server: {ex.Message}");
147 return 1;
148 }
149 }
150
151 private async Task<int> SyncUp(string workspacePath, string? description)
152 {
153 try
154 {
155 var trunk = await client.GetTrunkStateAsync(projectId);
156 var filter = await IgnoreFilter.LoadAsync(workspacePath);
157 var localFiles = await WorkspaceScanner.ScanAsync(workspacePath, filter);
158 var diff = WorkspaceDiff.Compute(localFiles, trunk.Files);
159
160 if (!diff.HasChanges)
161 {
162 Console.WriteLine("No changes to push.");
163 return 0;
164 }
165
166 Console.WriteLine($"Pushing: {diff.Added.Count} added, {diff.Modified.Count} modified, {diff.Deleted.Count} deleted");
167
168 var ops = new List<FileOperation>();
169
170 foreach (var file in diff.Added)
171 {
172 var content = await File.ReadAllBytesAsync(Path.Combine(workspacePath, file));
173 ops.Add(new FileOperation { Type = OpType.Insert, File = file, ContentBase64 = Convert.ToBase64String(content) });
174 }
175
176 foreach (var file in diff.Modified)
177 {
178 var content = await File.ReadAllBytesAsync(Path.Combine(workspacePath, file));
179 ops.Add(new FileOperation { Type = OpType.Modify, File = file, ContentBase64 = Convert.ToBase64String(content) });
180 }
181
182 foreach (var file in diff.Deleted)
183 {
184 ops.Add(new FileOperation { Type = OpType.Delete, File = file });
185 }
186
187 var request = new CreatePatchRequest
188 {
189 Author = Environment.UserName,
190 Description = description ?? $"Sync: {diff.Added.Count} added, {diff.Modified.Count} modified, {diff.Deleted.Count} deleted",
191 Maturity = Maturity.DraftSynced,
192 BaseHash = trunk.Hash,
193 Ops = ops
194 };
195
196 var patch = await client.CreatePatchAsync(projectId, request);
197 Console.WriteLine($"Synced up: patch {patch.Id} created ({patch.OpCount} ops)");
198 return 0;
199 }
200 catch (HttpRequestException ex)
201 {
202 Console.Error.WriteLine($"Cannot reach server: {ex.Message}");
203 return 1;
204 }
205 }
206}
207
+ src/Catena.Client/Commands/WithdrawCommand.cs
1using Catena.Shared.Models;
2
3namespace Catena.Client.Commands;
4
5public sealed class WithdrawCommand(CatenaClient client, string projectId)
6{
7 public async Task<int> RunAsync(string? patchId)
8 {
9 if (patchId is null)
10 {
11 Console.Error.WriteLine("Patch ID required. Usage: catena withdraw <id>");
12 return 1;
13 }
14
15 try
16 {
17 var (patch, error) = await client.UpdateMaturityAsync(projectId, patchId, Maturity.DraftSynced);
18 if (error is not null)
19 {
20 Console.Error.WriteLine($"Failed to withdraw: {error}");
21 return 1;
22 }
23
24 Console.WriteLine($"Patch {patch!.Id} withdrawn to draft. You can now make changes and re-propose.");
25 return 0;
26 }
27 catch (HttpRequestException ex)
28 {
29 Console.Error.WriteLine($"Cannot reach server: {ex.Message}");
30 return 1;
31 }
32 }
33}
34
+ src/Catena.Client/Config/WorkspaceConfig.cs
1using System.Text.Json;
2
3namespace Catena.Client.Config;
4
5public sealed record WorkspaceConfig
6{
7 public required string ServerUrl { get; init; }
8 public required string ProjectId { get; init; }
9 public string? ApiKey { get; init; }
10
11 public static string ConfigDir(string workspacePath) =>
12 Path.Combine(workspacePath, ".catena");
13
14 public static string ConfigFile(string workspacePath) =>
15 Path.Combine(ConfigDir(workspacePath), "config.json");
16
17 public async Task SaveAsync(string workspacePath)
18 {
19 Directory.CreateDirectory(ConfigDir(workspacePath));
20 await File.WriteAllTextAsync(ConfigFile(workspacePath), JsonSerializer.Serialize(this, CatenaJsonContext.Default.WorkspaceConfig));
21 }
22
23 public static async Task<WorkspaceConfig?> LoadAsync(string workspacePath)
24 {
25 var file = ConfigFile(workspacePath);
26 if (!File.Exists(file)) return null;
27 var json = await File.ReadAllTextAsync(file);
28 return JsonSerializer.Deserialize(json, CatenaJsonContext.Default.WorkspaceConfig);
29 }
30}
31
+ src/Catena.Client/Program.cs
1using Catena.Client;
2using Catena.Client.Commands;
3using Catena.Client.Config;
4
5if (args.Length == 0)
6{
7 PrintUsage();
8 return 1;
9}
10
11var command = args[0];
12
13// init is special — no config exists yet
14if (command == "init")
15 return await HandleInit(args);
16
17if (command is "--help" or "-h")
18 return PrintUsage();
19
20// All other commands need an existing workspace
21var path = ParsePath(args);
22var config = await WorkspaceConfig.LoadAsync(path);
23if (config is null)
24{
25 Console.Error.WriteLine("Not a catena workspace. Run 'catena init' first.");
26 return 1;
27}
28
29var client = BuildClient(config);
30
31return command switch
32{
33 "status" => await new StatusCommand(client, config.ProjectId).RunAsync(path),
34 "draft" => await HandleDraft(args, client, config.ProjectId, path),
35 "propose" => await new ProposeCommand(client, config.ProjectId).RunAsync(ParsePositional(args)),
36 "accept" => await new AcceptCommand(client, config.ProjectId).RunAsync(ParsePositional(args)),
37 "log" => await HandleLog(args, client, config.ProjectId),
38 "diff" => await new DiffCommand(client, config.ProjectId).RunAsync(ParsePositional(args)),
39 "release" => await new ReleaseCommand(client, config.ProjectId).RunAsync(ParsePositional(args)),
40 "withdraw" => await new WithdrawCommand(client, config.ProjectId).RunAsync(ParsePositional(args)),
41 "sync" => await HandleSync(args, client, config.ProjectId, path),
42 _ => PrintUnknown(command)
43};
44
45// === Helpers ===
46
47static CatenaClient BuildClient(WorkspaceConfig config)
48{
49 var http = new HttpClient { BaseAddress = new Uri(config.ServerUrl) };
50 if (config.ApiKey is not null)
51 http.DefaultRequestHeaders.Add("X-Api-Key", config.ApiKey);
52 return new CatenaClient(http);
53}
54
55static string ParsePath(string[] args)
56{
57 for (int i = 1; i < args.Length; i++)
58 {
59 if (args[i] == "--path" && i + 1 < args.Length)
60 return Path.GetFullPath(args[i + 1]);
61 }
62 return Directory.GetCurrentDirectory();
63}
64
65static string? ParsePositional(string[] args)
66{
67 for (int i = 1; i < args.Length; i++)
68 {
69 if (args[i].StartsWith("--"))
70 {
71 i++; // skip flag value
72 continue;
73 }
74 return args[i];
75 }
76 return null;
77}
78
79static async Task<int> HandleInit(string[] args)
80{
81 string? serverUrl = null;
82 string? newName = null;
83 string? projectId = null;
84 string? path = null;
85 string? apiKey = null;
86 bool isPublic = false;
87
88 for (int i = 1; i < args.Length; i++)
89 {
90 switch (args[i])
91 {
92 case "--server" when i + 1 < args.Length: serverUrl = args[++i]; break;
93 case "--new" when i + 1 < args.Length: newName = args[++i]; break;
94 case "--project" when i + 1 < args.Length: projectId = args[++i]; break;
95 case "--path" when i + 1 < args.Length: path = args[++i]; break;
96 case "--api-key" when i + 1 < args.Length: apiKey = args[++i]; break;
97 case "--public": isPublic = true; break;
98 }
99 }
100
101 serverUrl ??= "http://localhost:5000";
102 path = Path.GetFullPath(path ?? Directory.GetCurrentDirectory());
103 return await InitCommand.RunAsync(serverUrl, newName, projectId, path, apiKey, isPublic);
104}
105
106static async Task<int> HandleDraft(string[] args, CatenaClient client, string projectId, string path)
107{
108 string? description = null;
109 bool sync = false;
110 var dependsOn = new List<string>();
111 var targets = new List<string>();
112
113 for (int i = 1; i < args.Length; i++)
114 {
115 switch (args[i])
116 {
117 case "--sync": sync = true; break;
118 case "-m" when i + 1 < args.Length: description = args[++i]; break;
119 case "--depends-on" when i + 1 < args.Length: dependsOn.Add(args[++i]); break;
120 case "--target" when i + 1 < args.Length: targets.Add(args[++i]); break;
121 }
122 }
123
124 return await new DraftCommand(client, projectId).RunAsync(path, sync, description, dependsOn, targets);
125}
126
127static async Task<int> HandleLog(string[] args, CatenaClient client, string projectId)
128{
129 string? file = null;
130 string? author = null;
131
132 for (int i = 1; i < args.Length; i++)
133 {
134 switch (args[i])
135 {
136 case "--file" when i + 1 < args.Length: file = args[++i]; break;
137 case "--author" when i + 1 < args.Length: author = args[++i]; break;
138 }
139 }
140
141 return await new LogCommand(client, projectId).RunAsync(file, author);
142}
143
144static async Task<int> HandleSync(string[] args, CatenaClient client, string projectId, string path)
145{
146 string? patchId = null;
147 string? description = null;
148 string direction = "down";
149 bool explicitDown = false;
150
151 for (int i = 1; i < args.Length; i++)
152 {
153 switch (args[i])
154 {
155 case "up": direction = "up"; break;
156 case "down": direction = "down"; explicitDown = true; break;
157 case "--patch" when i + 1 < args.Length: patchId = args[++i]; break;
158 case "-m" when i + 1 < args.Length: description = args[++i]; break;
159 }
160 }
161
162 return await new SyncCommand(client, projectId).RunAsync(path, direction, patchId, description, explicitDown);
163}
164
165static int PrintUsage()
166{
167 Console.WriteLine("""
168 catena - Patch-based version control
169
170 Usage: catena <command> [options]
171
172 Commands:
173 init Initialize workspace
174 --new <name> Create new project on server
175 --project <id> Connect to existing project
176 --server <url> Server URL (default: http://localhost:5000)
177 --path <dir> Workspace directory (default: current dir)
178 --api-key <key> API key for authentication
179
180 status Show workspace status
181 draft Create a draft patch
182 --sync Sync to server
183 -m <message> Description
184 propose Submit patch for validation
185 accept Accept a patch into trunk
186 log Show patch history
187 --file <path> Filter by file
188 --author <name> Filter by author
189 diff Show patch changes
190 revert Create inverse patch
191 release Create release snapshot
192 sync Sync workspace to trunk
193 --patch <id> Overlay a specific patch
194 server Start server
195
196 Global options:
197 --path <dir> Workspace directory (default: current dir)
198 """);
199 return 0;
200}
201
202static int PrintUnknown(string command)
203{
204 Console.Error.WriteLine($"Unknown command: {command}. Run 'catena --help' for usage.");
205 return 1;
206}
207
+ src/Catena.Server/Api/AuthEndpoints.cs
1using System.Security.Claims;
2using Catena.Shared.Dtos;
3using Catena.Storage;
4using Microsoft.AspNetCore.Authentication;
5using Microsoft.AspNetCore.Authentication.Cookies;
6
7namespace Catena.Server.Api;
8
9public static class AuthEndpoints
10{
11 public static void MapAuthApi(this WebApplication app)
12 {
13 var group = app.MapGroup("/auth");
14
15 group.MapPost("/login", async (LoginRequest request, UserStore userStore, HttpContext ctx) =>
16 {
17 if (string.IsNullOrWhiteSpace(request.ApiKey))
18 return Results.BadRequest("API key is required.");
19
20 var user = await userStore.GetByApiKeyAsync(request.ApiKey);
21 if (user is null)
22 return Results.Unauthorized();
23
24 var claims = new[]
25 {
26 new Claim(ClaimTypes.NameIdentifier, user.Id),
27 new Claim(ClaimTypes.Name, user.Name),
28 new Claim(ClaimTypes.Role, user.Role.ToString())
29 };
30 var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
31 var principal = new ClaimsPrincipal(identity);
32
33 await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
34
35 return Results.Ok(new LoginResponse(user.Id, user.Name, user.Role));
36 });
37
38 // Web login — form POST from /login page, sets cookie and redirects
39 group.MapPost("/web-login", async (HttpContext ctx, UserStore userStore) =>
40 {
41 var form = await ctx.Request.ReadFormAsync();
42 var apiKey = form["apiKey"].ToString();
43
44 if (string.IsNullOrWhiteSpace(apiKey))
45 return Results.Redirect("/login?error=1");
46
47 var user = await userStore.GetByApiKeyAsync(apiKey);
48 if (user is null)
49 return Results.Redirect("/login?error=1");
50
51 var claims = new[]
52 {
53 new Claim(ClaimTypes.NameIdentifier, user.Id),
54 new Claim(ClaimTypes.Name, user.Name),
55 new Claim(ClaimTypes.Role, user.Role.ToString())
56 };
57 var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
58 var principal = new ClaimsPrincipal(identity);
59
60 await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
61 return Results.Redirect("/app");
62 }).DisableAntiforgery();
63
64 group.MapGet("/logout", async (HttpContext ctx) =>
65 {
66 await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
67 return Results.Redirect("/");
68 });
69
70 group.MapGet("/me", (HttpContext ctx) =>
71 {
72 if (ctx.User.Identity?.IsAuthenticated != true)
73 return Results.Unauthorized();
74
75 return Results.Ok(new LoginResponse(
76 ctx.User.FindFirstValue(ClaimTypes.NameIdentifier)!,
77 ctx.User.FindFirstValue(ClaimTypes.Name)!,
78 Enum.Parse<Catena.Shared.Models.UserRole>(ctx.User.FindFirstValue(ClaimTypes.Role)!)
79 ));
80 });
81 }
82}
83
+ src/Catena.Server/Api/OverlapEndpoints.cs
1using Catena.Storage;
2using Catena.Shared.Dtos;
3using Catena.Shared.Models;
4using Catena.Shared.Overlap;
5
6namespace Catena.Server.Api;
7
8public static class OverlapEndpoints
9{
10 public static void MapOverlapApi(this WebApplication app)
11 {
12 var group = app.MapGroup("/projects/{projectId}");
13
14 group.MapGet("/overlaps", async (string projectId, PatchStore patchStore, ProjectStore projectStore) =>
15 {
16 var project = await projectStore.GetAsync(projectId);
17 if (project is null) return Results.NotFound("Project not found.");
18
19 var proposed = await patchStore.ListAsync(projectId, maturityFilter: Maturity.Proposed);
20 if (proposed.Count < 2)
21 return Results.Ok(Array.Empty<OverlapReportResponse>());
22
23 var reports = new List<OverlapReportResponse>();
24
25 for (int i = 0; i < proposed.Count; i++)
26 {
27 for (int j = i + 1; j < proposed.Count; j++)
28 {
29 var report = OverlapDetector.Detect(proposed[i], proposed[j]);
30 if (report.Results.Count == 0) continue;
31
32 reports.Add(new OverlapReportResponse
33 {
34 PatchIdA = report.PatchIdA,
35 PatchIdB = report.PatchIdB,
36 HasConflicts = report.HasConflicts,
37 Items = report.Results.Select(r => new OverlapItemResponse
38 {
39 Kind = r.Kind,
40 FileA = r.FileA,
41 FileB = r.FileB,
42 OpIndexA = r.OpIndexA,
43 OpIndexB = r.OpIndexB,
44 Reason = r.Reason
45 }).ToList()
46 });
47 }
48 }
49
50 return Results.Ok(reports);
51 });
52
53 group.MapGet("/patches/{patchId}/conflicts", async (string projectId, string patchId,
54 PatchStore patchStore) =>
55 {
56 var patch = await patchStore.GetAsync(projectId, patchId);
57 if (patch is null) return Results.NotFound("Patch not found.");
58
59 var proposed = await patchStore.ListAsync(projectId, maturityFilter: Maturity.Proposed);
60 var others = proposed.Where(p => p.Id != patchId).ToList();
61
62 var conflicts = new List<OverlapReportResponse>();
63
64 foreach (var other in others)
65 {
66 var report = OverlapDetector.Detect(patch, other);
67 if (!report.HasConflicts) continue;
68
69 conflicts.Add(new OverlapReportResponse
70 {
71 PatchIdA = report.PatchIdA,
72 PatchIdB = report.PatchIdB,
73 HasConflicts = true,
74 Items = report.Conflicts.Select(r => new OverlapItemResponse
75 {
76 Kind = r.Kind,
77 FileA = r.FileA,
78 FileB = r.FileB,
79 OpIndexA = r.OpIndexA,
80 OpIndexB = r.OpIndexB,
81 Reason = r.Reason
82 }).ToList()
83 });
84 }
85
86 return Results.Ok(conflicts);
87 });
88 }
89}
90
+ src/Catena.Server/Api/PatchEndpoints.cs
1using Catena.Server.Auth;
2using Catena.Storage;
3using Catena.Shared.Dtos;
4using Catena.Shared.Models;
5
6namespace Catena.Server.Api;
7
8public static class PatchEndpoints
9{
10 public static void MapPatchApi(this WebApplication app)
11 {
12 var group = app.MapGroup("/projects/{projectId}/patches");
13
14 group.MapPost("/", async (string projectId, CreatePatchRequest request, PatchStore store, ProjectStore projectStore, CatenaEvents events, HttpContext ctx) =>
15 {
16 var project = await projectStore.GetAsync(projectId);
17 if (project is null) return Results.NotFound("Project not found.");
18
19 var author = ctx.GetUserId() is not null ? ctx.GetUserName() : request.Author;
20 if (string.IsNullOrWhiteSpace(author))
21 return Results.BadRequest("Author is required.");
22 if (request.Ops.Count == 0)
23 return Results.BadRequest("At least one operation is required.");
24
25 var authenticatedRequest = request with { Author = author };
26 var patch = await store.CreateAsync(projectId, authenticatedRequest);
27 events.NotifyPatchChanged(projectId);
28 return Results.Created($"/projects/{projectId}/patches/{patch.Id}", ToPatchResponse(patch));
29 });
30
31 group.MapGet("/", async (string projectId, PatchStore store, ProjectStore projectStore, Maturity? maturity, string? author) =>
32 {
33 var project = await projectStore.GetAsync(projectId);
34 if (project is null) return Results.NotFound("Project not found.");
35
36 var patches = await store.ListAsync(projectId, maturity, author);
37 return Results.Ok(patches.Select(ToPatchResponse));
38 });
39
40 group.MapGet("/{patchId}", async (string projectId, string patchId, PatchStore store) =>
41 {
42 var patch = await store.GetAsync(projectId, patchId);
43 return patch is null ? Results.NotFound() : Results.Ok(ToPatchResponse(patch));
44 });
45
46 group.MapPut("/{patchId}/maturity", async (string projectId, string patchId,
47 UpdateMaturityRequest request, PatchStore store, TrunkState trunk, ReleaseStore releaseStore, CatenaEvents events, HttpContext ctx) =>
48 {
49 if (request.Maturity == Maturity.Accepted && !ctx.CanAccept())
50 return Results.Forbid();
51
52 var (patch, error) = await store.UpdateMaturityAsync(projectId, patchId, request.Maturity);
53 if (error is not null)
54 return patch is null ? Results.NotFound(error) : Results.BadRequest(error);
55
56 if (request.Maturity == Maturity.Accepted)
57 {
58 await trunk.ApplyPatchAsync(projectId, patch!, store);
59 events.NotifyTrunkChanged(projectId);
60
61 foreach (var target in patch!.Targets.Where(t => t != "trunk"))
62 {
63 await releaseStore.ApplyPatchAsync(projectId, target, patch, store);
64 events.NotifyReleaseChanged(projectId);
65 }
66 }
67
68 events.NotifyPatchChanged(projectId);
69 return Results.Ok(ToPatchResponse(patch!));
70 });
71
72 group.MapGet("/{patchId}/diff", async (string projectId, string patchId, PatchStore store) =>
73 {
74 var patch = await store.GetAsync(projectId, patchId);
75 if (patch is null) return Results.NotFound();
76
77 return Results.Ok(new PatchDiffResponse
78 {
79 PatchId = patch.Id,
80 Author = patch.Author,
81 Description = patch.Description,
82 Timestamp = patch.Timestamp,
83 Entries = patch.Ops.Select(op => new DiffEntry
84 {
85 Type = op.Type,
86 File = op.File,
87 Bytes = op.EndByte - op.StartByte,
88 LinesAdded = op.LinesAdded,
89 LinesDeleted = op.LinesDeleted,
90 NewPath = op.NewPath
91 }).ToList()
92 });
93 });
94
95 group.MapGet("/{patchId}/blobs/{opIndex:int}", async (string projectId, string patchId, int opIndex, PatchStore store) =>
96 {
97 var blob = await store.GetBlobAsync(projectId, patchId, opIndex);
98 if (blob is null) return Results.NotFound();
99 return Results.Bytes(blob, contentType: "application/octet-stream");
100 });
101 }
102
103 private static PatchResponse ToPatchResponse(Patch patch) => new()
104 {
105 Id = patch.Id,
106 Author = patch.Author,
107 ProjectId = patch.ProjectId,
108 Maturity = patch.Maturity,
109 Timestamp = patch.Timestamp,
110 Description = patch.Description,
111 Dependencies = patch.Dependencies,
112 Targets = patch.Targets,
113 OpCount = patch.Ops.Count
114 };
115}
116
+ src/Catena.Server/Api/ProjectEndpoints.cs
1using Catena.Server.Auth;
2using Catena.Storage;
3using Catena.Shared.Dtos;
4
5namespace Catena.Server.Api;
6
7public static class ProjectEndpoints
8{
9 public static void MapProjectApi(this WebApplication app)
10 {
11 var group = app.MapGroup("/projects");
12
13 group.MapPost("/", async (CreateProjectRequest request, ProjectStore store, HttpContext ctx) =>
14 {
15 if (!ctx.IsAuthenticated())
16 return Results.Unauthorized();
17 if (string.IsNullOrWhiteSpace(request.Name))
18 return Results.BadRequest("Project name is required.");
19
20 var project = await store.CreateAsync(request.Name, request.IsPublic);
21 return Results.Created($"/projects/{project.Id}",
22 new ProjectResponse(project.Id, project.Name, project.IsPublic, project.CreatedAt));
23 });
24
25 group.MapGet("/", async (ProjectStore store, HttpContext ctx) =>
26 {
27 var projects = await store.ListAsync();
28 var isAuthenticated = ctx.User.Identity?.IsAuthenticated == true;
29
30 if (!isAuthenticated)
31 projects = projects.Where(p => p.IsPublic).ToList();
32
33 return Results.Ok(projects.Select(p =>
34 new ProjectResponse(p.Id, p.Name, p.IsPublic, p.CreatedAt)));
35 });
36
37 group.MapGet("/{id}", async (string id, ProjectStore store, HttpContext ctx) =>
38 {
39 var project = await store.GetAsync(id);
40 if (project is null) return Results.NotFound();
41
42 var isAuthenticated = ctx.User.Identity?.IsAuthenticated == true;
43 if (!project.IsPublic && !isAuthenticated)
44 return Results.NotFound();
45
46 return Results.Ok(new ProjectResponse(project.Id, project.Name, project.IsPublic, project.CreatedAt));
47 });
48 }
49}
50
+ src/Catena.Server/Api/ReleaseEndpoints.cs
1using Catena.Server.Auth;
2using Catena.Storage;
3using Catena.Shared.Dtos;
4
5namespace Catena.Server.Api;
6
7public static class ReleaseEndpoints
8{
9 public static void MapReleaseApi(this WebApplication app)
10 {
11 var group = app.MapGroup("/projects/{projectId}/releases");
12
13 group.MapPost("/", async (string projectId, CreateReleaseRequest request,
14 ReleaseStore releaseStore, ProjectStore projectStore, PatchStore patchStore, TrunkState trunk, HttpContext ctx) =>
15 {
16 if (!ctx.CanAccept())
17 return Results.Forbid();
18
19 var project = await projectStore.GetAsync(projectId);
20 if (project is null) return Results.NotFound("Project not found.");
21
22 if (string.IsNullOrWhiteSpace(request.Version))
23 return Results.BadRequest("Version is required.");
24
25 if (releaseStore.Exists(projectId, request.Version))
26 return Results.Conflict($"Release {request.Version} already exists.");
27
28 var files = await trunk.GetFilesAsync(projectId);
29 var hash = TrunkState.ComputeHash(files);
30
31 // Collect accepted patch IDs for this release
32 var accepted = await patchStore.ListAsync(projectId, maturityFilter: Catena.Shared.Models.Maturity.Accepted);
33 var patchIds = accepted.Select(p => p.Id).ToList();
34 var basePatchId = accepted.FirstOrDefault()?.Id;
35
36 var release = await releaseStore.CreateAsync(projectId, request.Version, files, hash, patchIds, basePatchId);
37
38 return Results.Created($"/projects/{projectId}/releases/{release.Version}",
39 ToResponse(release));
40 });
41
42 group.MapGet("/", async (string projectId, ReleaseStore releaseStore, ProjectStore projectStore) =>
43 {
44 var project = await projectStore.GetAsync(projectId);
45 if (project is null) return Results.NotFound("Project not found.");
46
47 var releases = await releaseStore.ListAsync(projectId);
48 return Results.Ok(releases.Select(ToResponse));
49 });
50
51 group.MapGet("/{version}", async (string projectId, string version, ReleaseStore releaseStore) =>
52 {
53 var release = await releaseStore.GetAsync(projectId, version);
54 return release is null ? Results.NotFound() : Results.Ok(ToResponse(release));
55 });
56
57 group.MapGet("/{version}/files", async (string projectId, string version, ReleaseStore releaseStore) =>
58 {
59 var release = await releaseStore.GetAsync(projectId, version);
60 return release is null ? Results.NotFound() : Results.Ok(release.Files);
61 });
62 }
63
64 private static ReleaseResponse ToResponse(Catena.Shared.Models.Release release) => new()
65 {
66 Version = release.Version,
67 ProjectId = release.ProjectId,
68 CreatedAt = release.CreatedAt,
69 FileCount = release.Files.Count,
70 TrunkHash = release.TrunkHash
71 };
72}
73
+ src/Catena.Server/Api/ReviewEndpoints.cs
1using Catena.Server.Auth;
2using Catena.Shared.Dtos;
3using Catena.Storage;
4
5namespace Catena.Server.Api;
6
7public static class ReviewEndpoints
8{
9 public static void MapReviewApi(this WebApplication app)
10 {
11 var group = app.MapGroup("/projects/{projectId}/patches/{patchId}/reviews");
12
13 group.MapPost("/", async (string projectId, string patchId, CreateReviewRequest request,
14 PatchStore patchStore, ReviewStore reviewStore, CatenaEvents events, HttpContext ctx) =>
15 {
16 var patch = await patchStore.GetAsync(projectId, patchId);
17 if (patch is null) return Results.NotFound("Patch not found.");
18
19 var userId = ctx.GetUserId() ?? "anonymous";
20 var userName = ctx.GetUserName();
21
22 var review = await reviewStore.CreateAsync(projectId, patchId, userId, userName,
23 request.FilePath, request.LineNumber, request.Comment);
24 events.NotifyReviewChanged(projectId);
25 return Results.Created($"/projects/{projectId}/patches/{patchId}/reviews/{review.Id}",
26 ToResponse(review));
27 });
28
29 group.MapGet("/", async (string projectId, string patchId, ReviewStore reviewStore) =>
30 {
31 var reviews = await reviewStore.ListForPatchAsync(projectId, patchId);
32 return Results.Ok(reviews.Select(ToResponse));
33 });
34 }
35
36 private static ReviewResponse ToResponse(Catena.Shared.Models.Review r) =>
37 new(r.Id, r.PatchId, r.ReviewerId, r.ReviewerName, r.Status, r.Comment,
38 r.FilePath, r.LineNumber, r.CreatedAt, r.UpdatedAt);
39}
40
+ src/Catena.Server/Api/TrunkEndpoints.cs
1using Catena.Server.Auth;
2using Catena.Storage;
3using Catena.Shared.Dtos;
4using Catena.Shared.Models;
5
6namespace Catena.Server.Api;
7
8public static class TrunkEndpoints
9{
10 public static void MapTrunkApi(this WebApplication app)
11 {
12 var group = app.MapGroup("/projects/{projectId}/trunk");
13
14 group.MapGet("/", async (string projectId, TrunkState trunk, ProjectStore projectStore, HttpContext ctx) =>
15 {
16 var (project, accessError) = await ProjectAccessHelper.GetReadableProjectAsync(projectStore, projectId, ctx);
17 if (accessError is not null) return accessError;
18
19 var files = await trunk.GetFilesAsync(projectId);
20 return Results.Ok(new TrunkStateResponse
21 {
22 Files = files,
23 Hash = TrunkState.ComputeHash(files)
24 });
25 });
26
27 group.MapGet("/history", async (string projectId, PatchStore patchStore, ProjectStore projectStore,
28 HttpContext ctx, string? file, string? author) =>
29 {
30 var (project, accessError) = await ProjectAccessHelper.GetReadableProjectAsync(projectStore, projectId, ctx);
31 if (accessError is not null) return accessError;
32
33 var accepted = await patchStore.ListAsync(projectId, maturityFilter: Maturity.Accepted, authorFilter: author);
34
35 if (file is not null)
36 {
37 accepted = accepted
38 .Where(p => p.Ops.Any(op => op.File == file || op.NewPath == file))
39 .ToList();
40 }
41
42 return Results.Ok(accepted.Select(p => new PatchResponse
43 {
44 Id = p.Id,
45 Author = p.Author,
46 ProjectId = p.ProjectId,
47 Maturity = p.Maturity,
48 Timestamp = p.Timestamp,
49 Description = p.Description,
50 Dependencies = p.Dependencies,
51 Targets = p.Targets,
52 OpCount = p.Ops.Count
53 }));
54 });
55
56 group.MapGet("/tree", async (string projectId, TrunkState trunk, ProjectStore projectStore, HttpContext ctx) =>
57 {
58 var (project, accessError) = await ProjectAccessHelper.GetReadableProjectAsync(projectStore, projectId, ctx);
59 if (accessError is not null) return accessError;
60
61 var files = await trunk.GetFilesAsync(projectId);
62 var root = new List<FileTreeEntry>();
63
64 foreach (var filePath in files.Keys.OrderBy(f => f))
65 {
66 var parts = filePath.Split('/');
67 var current = root;
68
69 for (int i = 0; i < parts.Length; i++)
70 {
71 var part = parts[i];
72 var isFile = i == parts.Length - 1;
73 var fullPath = string.Join("/", parts[..( i + 1)]);
74
75 var existing = current.FirstOrDefault(e => e.Name == part);
76 if (existing is null)
77 {
78 existing = new FileTreeEntry
79 {
80 Name = part,
81 Path = isFile ? fullPath : fullPath + "/",
82 IsDirectory = !isFile
83 };
84 current.Add(existing);
85 }
86 current = existing.Children;
87 }
88 }
89
90 return Results.Ok(root);
91 });
92
93 group.MapGet("/files/{**path}", async (string projectId, string path,
94 TrunkState trunk, PatchStore patchStore) =>
95 {
96 var blobIndex = await trunk.GetBlobIndexAsync(projectId);
97 if (!blobIndex.TryGetValue(path, out var blobRef))
98 return Results.NotFound("File not found in trunk.");
99
100 var blob = await patchStore.GetBlobAsync(projectId, blobRef.PatchId, blobRef.OpIndex);
101 if (blob is null) return Results.NotFound("Blob missing.");
102
103 return Results.Bytes(blob, contentType: "application/octet-stream", fileDownloadName: Path.GetFileName(path));
104 });
105
106 group.MapPost("/download", async (string projectId, BulkDownloadRequest? request,
107 TrunkState trunk, PatchStore patchStore, HttpResponse response) =>
108 {
109 if (request?.Files is null || request.Files.Count == 0)
110 {
111 response.StatusCode = 400;
112 return;
113 }
114
115 var blobIndex = await trunk.GetBlobIndexAsync(projectId);
116
117 response.ContentType = "application/x-ndjson";
118 response.StatusCode = 200;
119
120 await using var writer = new StreamWriter(response.Body);
121
122 foreach (var filePath in request.Files)
123 {
124 if (!blobIndex.TryGetValue(filePath, out var blobRef)) continue;
125 var blob = await patchStore.GetBlobAsync(projectId, blobRef.PatchId, blobRef.OpIndex);
126 if (blob is null) continue;
127
128 var entry = new FileContentResponse
129 {
130 Path = filePath,
131 ContentBase64 = Convert.ToBase64String(blob)
132 };
133 await writer.WriteLineAsync(System.Text.Json.JsonSerializer.Serialize(entry));
134 await writer.FlushAsync();
135 }
136 });
137 }
138}
139
+ src/Catena.Server/Api/UserEndpoints.cs
1using Catena.Shared.Dtos;
2using Catena.Shared.Models;
3using Catena.Storage;
4
5namespace Catena.Server.Api;
6
7public static class UserEndpoints
8{
9 public static void MapUserApi(this WebApplication app)
10 {
11 var group = app.MapGroup("/users").RequireAuthorization("AdminOnly");
12
13 group.MapPost("/", async (CreateUserRequest request, UserStore store) =>
14 {
15 if (string.IsNullOrWhiteSpace(request.Name))
16 return Results.BadRequest("Name is required.");
17
18 var (user, plainKey) = await store.CreateAsync(request.Name, request.Role);
19 return Results.Created($"/users/{user.Id}",
20 new CreateUserResponse(user.Id, user.Name, user.Role, plainKey));
21 });
22
23 group.MapGet("/", async (UserStore store) =>
24 {
25 var users = await store.ListAsync();
26 return Results.Ok(users.Select(u =>
27 new UserResponse(u.Id, u.Name, u.Role, u.CreatedAt)));
28 });
29
30 group.MapPut("/{id}/role", async (string id, UpdateRoleRequest request, UserStore store) =>
31 {
32 var updated = await store.UpdateRoleAsync(id, request.Role);
33 return updated is null
34 ? Results.NotFound()
35 : Results.Ok(new UserResponse(updated.Id, updated.Name, updated.Role, updated.CreatedAt));
36 });
37
38 group.MapDelete("/{id}", (string id, UserStore store) =>
39 {
40 return store.DeleteUser(id) ? Results.NoContent() : Results.NotFound();
41 });
42
43 // Public user lookup (any authenticated user)
44 app.MapGet("/users/{id}", async (string id, UserStore store) =>
45 {
46 var user = await store.GetAsync(id);
47 return user is null ? Results.NotFound()
48 : Results.Ok(new UserResponse(user.Id, user.Name, user.Role, user.CreatedAt));
49 });
50 }
51}
52
+ src/Catena.Server/Api/WebhookEndpoints.cs
1using Catena.Shared.Dtos;
2using Catena.Storage;
3
4namespace Catena.Server.Api;
5
6public static class WebhookEndpoints
7{
8 public static void MapWebhookApi(this WebApplication app)
9 {
10 var group = app.MapGroup("/projects/{projectId}/webhooks").RequireAuthorization("MaintainerOrAdmin");
11
12 group.MapPost("/", async (string projectId, CreateWebhookRequest request,
13 WebhookStore store, ProjectStore projectStore) =>
14 {
15 var project = await projectStore.GetAsync(projectId);
16 if (project is null) return Results.NotFound("Project not found.");
17
18 if (string.IsNullOrWhiteSpace(request.Event) || string.IsNullOrWhiteSpace(request.Url))
19 return Results.BadRequest("Event and URL are required.");
20
21 var webhook = await store.CreateAsync(projectId, request.Event, request.Url);
22 return Results.Created($"/projects/{projectId}/webhooks/{webhook.Id}",
23 new WebhookResponse(webhook.Id, webhook.ProjectId, webhook.Event, webhook.Url, webhook.CreatedAt));
24 });
25
26 group.MapGet("/", async (string projectId, WebhookStore store, ProjectStore projectStore) =>
27 {
28 var project = await projectStore.GetAsync(projectId);
29 if (project is null) return Results.NotFound("Project not found.");
30
31 var hooks = await store.ListAsync(projectId);
32 return Results.Ok(hooks.Select(h =>
33 new WebhookResponse(h.Id, h.ProjectId, h.Event, h.Url, h.CreatedAt)));
34 });
35
36 group.MapDelete("/{id}", (string projectId, string id, WebhookStore store) =>
37 {
38 return store.Delete(projectId, id) ? Results.Ok() : Results.NotFound();
39 });
40 }
41}
42
+ src/Catena.Server/Auth/ApiKeyAuthHandler.cs
1using System.Security.Claims;
2using System.Text.Encodings.Web;
3using Catena.Storage;
4using Microsoft.AspNetCore.Authentication;
5using Microsoft.Extensions.Options;
6
7namespace Catena.Server.Auth;
8
9public sealed class ApiKeyAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
10{
11 private readonly UserStore _userStore;
12 public const string SchemeName = "ApiKey";
13 public const string HeaderName = "X-Api-Key";
14
15 public ApiKeyAuthHandler(
16 IOptionsMonitor<AuthenticationSchemeOptions> options,
17 ILoggerFactory logger,
18 UrlEncoder encoder,
19 UserStore userStore) : base(options, logger, encoder)
20 {
21 _userStore = userStore;
22 }
23
24 protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
25 {
26 if (!Request.Headers.TryGetValue(HeaderName, out var keyValues))
27 return AuthenticateResult.NoResult();
28
29 var apiKey = keyValues.FirstOrDefault();
30 if (string.IsNullOrWhiteSpace(apiKey))
31 return AuthenticateResult.Fail("Empty API key.");
32
33 var user = await _userStore.GetByApiKeyAsync(apiKey);
34 if (user is null)
35 return AuthenticateResult.Fail("Invalid API key.");
36
37 var claims = new[]
38 {
39 new Claim(ClaimTypes.NameIdentifier, user.Id),
40 new Claim(ClaimTypes.Name, user.Name),
41 new Claim(ClaimTypes.Role, user.Role.ToString())
42 };
43
44 var identity = new ClaimsIdentity(claims, SchemeName);
45 var principal = new ClaimsPrincipal(identity);
46 var ticket = new AuthenticationTicket(principal, SchemeName);
47
48 return AuthenticateResult.Success(ticket);
49 }
50}
51
+ src/Catena.Server/Auth/AuthExtensions.cs
1using System.Security.Claims;
2
3namespace Catena.Server.Auth;
4
5public static class AuthExtensions
6{
7 public static string? GetUserId(this HttpContext ctx) =>
8 ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
9
10 public static string GetUserName(this HttpContext ctx) =>
11 ctx.User.FindFirstValue(ClaimTypes.Name) ?? "anonymous";
12
13 public static bool IsInRole(this HttpContext ctx, string role) =>
14 ctx.User.IsInRole(role);
15
16 public static bool IsMaintainerOrAdmin(this HttpContext ctx) =>
17 ctx.User.IsInRole("Maintainer") || ctx.User.IsInRole("Admin");
18
19 /// <summary>Returns true if auth is disabled (tests, dev). Skips all auth checks.</summary>
20 public static bool IsAuthDisabled(this HttpContext ctx) =>
21 ctx.RequestServices.GetService<IConfiguration>()?.GetValue("Catena:AuthEnabled", true) == false;
22
23 /// <summary>Returns true if the request is authenticated OR auth is disabled.</summary>
24 public static bool IsAuthenticated(this HttpContext ctx) =>
25 ctx.IsAuthDisabled() || ctx.GetUserId() is not null;
26
27 /// <summary>Returns true if the user has Maintainer/Admin role OR auth is disabled.</summary>
28 public static bool CanAccept(this HttpContext ctx) =>
29 ctx.IsAuthDisabled() || ctx.IsMaintainerOrAdmin();
30}
31
+ src/Catena.Server/Auth/ProjectAccessHelper.cs
1using Catena.Shared.Models;
2using Catena.Storage;
3
4namespace Catena.Server.Auth;
5
6public static class ProjectAccessHelper
7{
8 public static async Task<(Project? Project, IResult? Error)> GetReadableProjectAsync(
9 ProjectStore store, string projectId, HttpContext ctx)
10 {
11 var project = await store.GetAsync(projectId);
12 if (project is null) return (null, Results.NotFound("Project not found."));
13
14 if (!project.IsPublic && !ctx.IsAuthenticated())
15 return (null, Results.NotFound("Project not found."));
16
17 return (project, null);
18 }
19
20 public static async Task<(Project? Project, IResult? Error)> GetWritableProjectAsync(
21 ProjectStore store, string projectId, HttpContext ctx)
22 {
23 var project = await store.GetAsync(projectId);
24 if (project is null) return (null, Results.NotFound("Project not found."));
25
26 if (!ctx.IsAuthenticated())
27 return (null, Results.Unauthorized());
28
29 return (project, null);
30 }
31}
32
+ src/Catena.Server/Catena.Server.csproj
1<Project Sdk="Microsoft.NET.Sdk.Web">
2
3 <ItemGroup>
4 <ProjectReference Include="..\Catena.Shared\Catena.Shared.csproj" />
5 <ProjectReference Include="..\Catena.Storage\Catena.Storage.csproj" />
6 </ItemGroup>
7
8 <PropertyGroup>
9 <TargetFramework>net10.0</TargetFramework>
10 <Nullable>enable</Nullable>
11 <ImplicitUsings>enable</ImplicitUsings>
12 </PropertyGroup>
13
14</Project>
15
+ src/Catena.Server/Components/App.razor
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <base href="/" />
7 <title>Catena</title>
8 <link rel="icon" type="image/svg+xml" href="favicon.svg" />
9 <HeadOutlet @rendermode="RenderMode.InteractiveServer" />
10 <link rel="stylesheet" href="css/app.css" />
11 <style>
12 #components-reconnect-modal {
13 display:none;position:fixed;inset:0;z-index:9999;
14 background:rgba(9,9,11,.92);backdrop-filter:blur(8px);
15 }
16 #components-reconnect-modal.components-reconnect-show,
17 #components-reconnect-modal.components-reconnect-failed,
18 #components-reconnect-modal.components-reconnect-rejected {
19 display:flex;align-items:center;justify-content:center;
20 }
21 .rc-term {
22 background:#0a0a0c;border:1px solid #27272a;border-radius:8px;
23 padding:16px;width:320px;font-family:'JetBrains Mono',monospace;font-size:12px;
24 }
25 .rc-bar { display:flex;gap:6px;margin-bottom:12px }
26 .rc-dot { width:10px;height:10px;border-radius:50% }
27 .rc-line { color:#52525b;margin-bottom:4px;line-height:1.6 }
28 .rc-line .prompt { color:#22c55e }
29 .rc-line .cmd { color:#a1a1aa }
30 .rc-line .err { color:#ef4444 }
31 .rc-typing {
32 color:#22c55e;overflow:hidden;white-space:nowrap;
33 border-right:2px solid #22c55e;width:0;max-width:280px;
34 animation:rctype 2s steps(28) infinite, rcblink .6s step-end infinite alternate;
35 }
36 @@keyframes rctype { 0%{width:0} 50%,100%{width:220px} }
37 @@keyframes rcblink { 50%{border-color:transparent} }
38
39 /* Failed state */
40 .rc-connecting { display:block }
41 .rc-failed { display:none }
42 .components-reconnect-failed .rc-connecting,
43 .components-reconnect-rejected .rc-connecting { display:none }
44 .components-reconnect-failed .rc-failed,
45 .components-reconnect-rejected .rc-failed { display:block }
46 .rc-reload {
47 color:#22c55e;cursor:pointer;text-decoration:none;
48 }
49 .rc-reload:hover { text-decoration:underline }
50 </style>
51</head>
52<body>
53 <div id="components-reconnect-modal">
54 <div class="rc-term">
55 <div class="rc-bar">
56 <div class="rc-dot" style="background:#ef4444"></div>
57 <div class="rc-dot" style="background:#f59e0b"></div>
58 <div class="rc-dot" style="background:#22c55e"></div>
59 </div>
60 <div class="rc-line"><span class="prompt">$</span> <span class="cmd">catena status</span></div>
61 <div class="rc-line"><span class="err">connection lost</span></div>
62 <div class="rc-connecting">
63 <div class="rc-line"><span class="prompt">$</span> <span class="cmd">catena reconnect</span></div>
64 <div class="rc-typing">attempting to reach server...</div>
65 </div>
66 <div class="rc-failed">
67 <div class="rc-line"><span class="err">server unreachable.</span></div>
68 <div class="rc-line"><span class="prompt">$</span> <a class="rc-reload" onclick="location.reload()">catena reload</a></div>
69 </div>
70 </div>
71 </div>
72 <Routes @rendermode="RenderMode.InteractiveServer" />
73 <script src="_framework/blazor.web.js"></script>
74 <script src="js/timeline.js"></script>
75</body>
76</html>
77
+ src/Catena.Server/Components/Docs/DocsCli.razor
1<section id="cli">
2 <h2>CLI Reference</h2>
3 <div class="docs-cli-grid">
4 <div class="docs-cli-cmd"><code>catena init</code><span>Create or connect a workspace</span></div>
5 <div class="docs-cli-cmd"><code>catena status</code><span>Show local changes</span></div>
6 <div class="docs-cli-cmd"><code>catena sync up</code><span>Push changes to server</span></div>
7 <div class="docs-cli-cmd"><code>catena sync down</code><span>Pull trunk state</span></div>
8 <div class="docs-cli-cmd"><code>catena propose</code><span>Submit patch for review</span></div>
9 <div class="docs-cli-cmd"><code>catena accept &lt;id&gt;</code><span>Accept patch into trunk</span></div>
10 <div class="docs-cli-cmd"><code>catena withdraw &lt;id&gt;</code><span>Pull back a proposed patch</span></div>
11 <div class="docs-cli-cmd"><code>catena log</code><span>Show patch history</span></div>
12 <div class="docs-cli-cmd"><code>catena diff &lt;id&gt;</code><span>Show patch details</span></div>
13 <div class="docs-cli-cmd"><code>catena release &lt;v&gt;</code><span>Create release snapshot</span></div>
14 <div class="docs-cli-cmd"><code>catena draft</code><span>Set dependencies / targets</span></div>
15 </div>
16</section>
17
+ src/Catena.Server/Components/Docs/DocsConcepts.razor
1<section id="concepts">
2 <h2>Core Concepts</h2>
3
4 <h3>Patches</h3>
5 <p>A patch is a collection of file operations:</p>
6 <div class="docs-ops">
7 <span class="docs-op docs-op-insert">+ Insert</span>
8 <span class="docs-op docs-op-modify">~ Modify</span>
9 <span class="docs-op docs-op-delete">- Delete</span>
10 <span class="docs-op docs-op-rename">&gt; Rename</span>
11 </div>
12 <p>Every patch has an author, description, timestamp, and a <strong>Maturity</strong> level.</p>
13
14 <h3>Trunk</h3>
15 <p>The trunk is the current, correct state of the project. No <code>main</code> branch, no <code>develop</code>. Just the trunk. When a patch is accepted, the trunk grows — patch by patch, linearly.</p>
16</section>
17
+ src/Catena.Server/Components/Docs/DocsDependencies.razor
1<section id="dependencies">
2 <h3>Dependencies</h3>
3 <p>Patches can depend on each other. A patch can only be accepted once all its dependencies are accepted.</p>
4
5 <div class="docs-dep-chain">
6 <div class="docs-dep-node" style="border-color:#3b82f6"><span style="color:#3b82f6">Frontend Types</span></div>
7 <div class="docs-dep-line"></div>
8 <div class="docs-dep-node" style="border-color:#3b82f6"><span style="color:#3b82f6">API Auth Refactor</span></div>
9 <div class="docs-dep-line"></div>
10 <div class="docs-dep-multi">
11 <div class="docs-dep-node" style="border-color:#f59e0b"><span style="color:#f59e0b">Payment Retry</span></div>
12 <div class="docs-dep-node" style="border-color:#f59e0b"><span style="color:#f59e0b">Email Notify</span></div>
13 </div>
14 <div class="docs-dep-line" style="background:var(--accent)"></div>
15 <div class="docs-dep-node docs-dep-trunk"><span style="color:var(--accent)">Trunk (accepted)</span></div>
16 </div>
17 <p class="docs-muted" style="text-align:center;margin-top:8px">Accept order: Payment + Email → API Auth → Frontend Types</p>
18</section>
19
+ src/Catena.Server/Components/Docs/DocsDesign.razor
1<section id="design">
2 <h2>Design Decisions</h2>
3
4 <h3>Trunk, not Branches</h3>
5 <p>Catena has one trunk — the single source of truth. Parallel work happens through proposed patches: they live side by side, not in separate histories. Overlap detection shows immediately when two patches collide.</p>
6
7 <h3>Online-first</h3>
8 <p>The server is the source of truth. Sync is trivial, overlaps are instantly visible, comments are native. You can still work offline: <code>catena draft</code> creates local drafts.</p>
9
10 <h3>Linear History</h3>
11 <p>Every patch is atomic — either fully in trunk or not at all. The history is a chain, not a graph. This makes it easier to understand and debug.</p>
12</section>
13
14<footer class="docs-footer">
15 <span>Catena — Patch-based version control for teams that ship.</span>
16 <a href="/">← Back to Catena</a>
17</footer>
18
+ src/Catena.Server/Components/Docs/DocsFlows.razor
1<section id="flows">
2 <h2>Workflows</h2>
3
4 <div id="daily">
5 <h3>Daily Workflow</h3>
6 <div class="docs-flow-vertical">
7 <div class="docs-fv-step"><span class="docs-fv-cmd">catena sync down</span><span class="docs-muted">Pull trunk state</span></div>
8 <div class="docs-fv-arrow">↓</div>
9 <div class="docs-fv-step"><span class="docs-fv-action">Edit files</span></div>
10 <div class="docs-fv-arrow">↓</div>
11 <div class="docs-fv-step"><span class="docs-fv-cmd">catena status</span><span class="docs-muted">What changed?</span></div>
12 <div class="docs-fv-arrow">↓</div>
13 <div class="docs-fv-step"><span class="docs-fv-cmd">catena sync up -m "..."</span><span class="docs-muted">Push changes</span></div>
14 <div class="docs-fv-arrow">↓</div>
15 <div class="docs-fv-step"><span class="docs-fv-cmd">catena propose</span><span class="docs-muted">Submit for review</span></div>
16 <div class="docs-fv-arrow">↓</div>
17 <div class="docs-fv-step"><span class="docs-fv-action">Team comments inline</span></div>
18 <div class="docs-fv-arrow">↓</div>
19 <div class="docs-fv-step"><span class="docs-fv-cmd">catena accept &lt;id&gt;</span><span class="docs-muted">Maintainer accepts</span></div>
20 </div>
21 </div>
22
23 <div id="conflict">
24 <h3>Resolving Conflicts</h3>
25 <div class="docs-term"><div class="docs-term-bar"><span class="docs-dot-r"></span><span class="docs-dot-y"></span><span class="docs-dot-g"></span></div>
26 <div class="docs-term-body">
27 <div class="docs-term-line" style="color:var(--text3)"># Alice and Bob modify the same file</div>
28 <div class="docs-term-line" style="color:#ef4444">OVERLAP detected: Payment Retry × Email Notifications</div>
29 <div class="docs-term-line"></div>
30 <div class="docs-term-line" style="color:var(--text3)"># Bob withdraws, Alice gets accepted</div>
31 <div class="docs-term-line"><span class="docs-prompt">$</span> catena withdraw &lt;bob-id&gt;</div>
32 <div class="docs-term-line"><span class="docs-prompt">$</span> catena accept &lt;alice-id&gt;</div>
33 <div class="docs-term-line"></div>
34 <div class="docs-term-line" style="color:var(--text3)"># Bob syncs, adjusts, re-proposes</div>
35 <div class="docs-term-line"><span class="docs-prompt">$</span> catena sync down</div>
36 <div class="docs-term-line"><span class="docs-prompt">$</span> catena sync up -m "Email v2"</div>
37 <div class="docs-term-line"><span class="docs-prompt">$</span> catena propose</div>
38 </div></div>
39 </div>
40
41 <div id="dep-flow">
42 <h3>Building on a Patch</h3>
43 <div class="docs-term"><div class="docs-term-bar"><span class="docs-dot-r"></span><span class="docs-dot-y"></span><span class="docs-dot-g"></span></div>
44 <div class="docs-term-body">
45 <div class="docs-term-line" style="color:var(--text3)"># Pull Alice's patch as your base</div>
46 <div class="docs-term-line"><span class="docs-prompt">$</span> catena sync down --patch &lt;alice-id&gt;</div>
47 <div class="docs-term-line"></div>
48 <div class="docs-term-line" style="color:var(--text3)"># Work on top, push with dependency</div>
49 <div class="docs-term-line"><span class="docs-prompt">$</span> catena sync up -m "Auth service"</div>
50 <div class="docs-term-line"><span class="docs-prompt">$</span> catena draft --depends-on &lt;alice-id&gt;</div>
51 <div class="docs-term-line"><span class="docs-prompt">$</span> catena propose</div>
52 <div class="docs-term-line"></div>
53 <div class="docs-term-line" style="color:var(--text3)"># Accept enforces order: Alice → Bob</div>
54 </div></div>
55 </div>
56
57 <div id="hotfix">
58 <h3>Hotfix on Release + Trunk</h3>
59 <div class="docs-term"><div class="docs-term-bar"><span class="docs-dot-r"></span><span class="docs-dot-y"></span><span class="docs-dot-g"></span></div>
60 <div class="docs-term-body">
61 <div class="docs-term-line"><span class="docs-prompt">$</span> catena release v1.0.0</div>
62 <div class="docs-term-line"></div>
63 <div class="docs-term-line" style="color:var(--text3)"># Hotfix targeting both</div>
64 <div class="docs-term-line"><span class="docs-prompt">$</span> catena sync up -m "Hotfix login"</div>
65 <div class="docs-term-line"><span class="docs-prompt">$</span> catena draft --target trunk --target v1.0.0</div>
66 <div class="docs-term-line"></div>
67 <div class="docs-term-line" style="color:#f97316"># After accept: patch applied to trunk + release</div>
68 </div></div>
69 </div>
70</section>
71
+ src/Catena.Server/Components/Docs/DocsHero.razor
1<section class="docs-hero">
2 <h1>How it Works</h1>
3 <p class="docs-lead">Everything you need to know to work with Catena.</p>
4</section>
5
+ src/Catena.Server/Components/Docs/DocsMaturity.razor
1<section id="maturity">
2 <h3>Maturity — The Lifecycle of a Patch</h3>
3
4 <div class="docs-flow">
5 <div class="docs-flow-node docs-flow-draft">DraftLocal</div>
6 <div class="docs-flow-arrow">→</div>
7 <div class="docs-flow-node docs-flow-synced">DraftSynced</div>
8 <div class="docs-flow-arrow">→</div>
9 <div class="docs-flow-node docs-flow-shared">DraftShared</div>
10 <div class="docs-flow-arrow">→</div>
11 <div class="docs-flow-node docs-flow-proposed">Proposed</div>
12 <div class="docs-flow-arrow">→</div>
13 <div class="docs-flow-node docs-flow-accepted">Accepted</div>
14 </div>
15 <div class="docs-flow-withdraw">
16 <svg width="340" height="36" viewBox="0 0 340 36" style="display:block;margin:0 auto">
17 <path d="M 255 4 C 255 28, 80 28, 80 4" stroke="#3b82f6" stroke-width="1.5" fill="none" stroke-dasharray="4,3"/>
18 <text x="168" y="30" text-anchor="middle" font-size="10" fill="#3b82f6" font-family="'JetBrains Mono',monospace">← withdraw</text>
19 </svg>
20 </div>
21
22 <div class="docs-stages">
23 <div class="docs-stage"><span class="docs-badge docs-badge-draft">DraftLocal</span> Created locally, not yet synced</div>
24 <div class="docs-stage"><span class="docs-badge docs-badge-synced">DraftSynced</span> Saved on server, only visible to you</div>
25 <div class="docs-stage"><span class="docs-badge docs-badge-shared">DraftShared</span> Shared with team, others can build dependencies on it</div>
26 <div class="docs-stage"><span class="docs-badge docs-badge-proposed">Proposed</span> Submitted for review — candidate for the trunk</div>
27 <div class="docs-stage"><span class="docs-badge docs-badge-accepted">Accepted</span> Merged into trunk — now part of the truth</div>
28 </div>
29
30 <div class="docs-callout">
31 <strong>Withdraw:</strong> A proposed patch can be pulled back to DraftSynced with <code>catena withdraw</code> to continue working on it.
32 </div>
33</section>
34
+ src/Catena.Server/Components/Docs/DocsOverlaps.razor
1<section id="overlaps">
2 <h3>Overlaps (Conflicts)</h3>
3 <p>When two proposed patches modify the same file, Catena detects it <strong>immediately</strong>:</p>
4
5 <div class="docs-overlap-diagram">
6 <div class="docs-overlap-patch" style="border-color:#f59e0b">
7 <span class="docs-overlap-dot" style="background:#451a03;border-color:#f59e0b"></span>
8 <span>Payment Retry</span>
9 </div>
10 <div class="docs-overlap-line">
11 <div class="docs-overlap-hline"></div>
12 <div class="docs-overlap-badge">overlap</div>
13 </div>
14 <div class="docs-overlap-patch" style="border-color:#f59e0b">
15 <span class="docs-overlap-dot" style="background:#451a03;border-color:#f59e0b"></span>
16 <span>Email Notifications</span>
17 </div>
18 </div>
19
20 <div class="docs-overlap-results">
21 <div class="docs-overlap-result"><span style="color:var(--accent)">●</span> <strong>Auto-Apply</strong> — Different files, no problem</div>
22 <div class="docs-overlap-result"><span style="color:var(--text3)">●</span> <strong>Deduplicated</strong> — Same change, merged automatically</div>
23 <div class="docs-overlap-result"><span style="color:#ef4444">●</span> <strong>Conflict</strong> — Same file, different change → must be resolved</div>
24 </div>
25
26 <p>Overlaps are highlighted in red on the Timeline and in the Patches view.</p>
27</section>
28
+ src/Catena.Server/Components/Docs/DocsRoles.razor
1<section id="roles">
2 <h2>Roles</h2>
3 <div class="docs-roles">
4 <div class="docs-role">
5 <span class="docs-badge docs-badge-accepted">Developer</span>
6 <span>Create patches, propose, withdraw, view status</span>
7 </div>
8 <div class="docs-role">
9 <span class="docs-badge docs-badge-proposed">Maintainer</span>
10 <span>+ Accept patches, create releases</span>
11 </div>
12 <div class="docs-role">
13 <span class="docs-badge" style="background:#450a0a;color:#ef4444">Admin</span>
14 <span>+ Manage users, assign roles</span>
15 </div>
16 </div>
17</section>
18
+ src/Catena.Server/Components/Docs/DocsStart.razor
1<section id="start">
2 <h2>Getting Started</h2>
3
4 <div class="docs-steps">
5 <div class="docs-step">
6 <div class="docs-step-num">1</div>
7 <div class="docs-step-content">
8 <h4>Start the server</h4>
9 <div class="docs-term"><div class="docs-term-bar"><span class="docs-dot-r"></span><span class="docs-dot-y"></span><span class="docs-dot-g"></span></div>
10 <div class="docs-term-body"><div class="docs-term-line"><span class="docs-prompt">$</span> dotnet run --project src/Catena.Server</div>
11 <div class="docs-term-line" style="color:var(--accent)">Admin API Key: xxxxxxxx</div></div></div>
12 <p class="docs-muted">On first run an admin key is displayed. Save it!</p>
13 </div>
14 </div>
15
16 <div class="docs-step">
17 <div class="docs-step-num">2</div>
18 <div class="docs-step-content">
19 <h4>Create a project</h4>
20 <div class="docs-term"><div class="docs-term-bar"><span class="docs-dot-r"></span><span class="docs-dot-y"></span><span class="docs-dot-g"></span></div>
21 <div class="docs-term-body"><div class="docs-term-line"><span class="docs-prompt">$</span> catena init --new "My Project" --api-key &lt;KEY&gt;</div>
22 <div class="docs-term-line" style="color:var(--text3)">Workspace initialized in .catena/</div></div></div>
23 </div>
24 </div>
25
26 <div class="docs-step">
27 <div class="docs-step-num">3</div>
28 <div class="docs-step-content">
29 <h4>Make changes &amp; check status</h4>
30 <div class="docs-term"><div class="docs-term-bar"><span class="docs-dot-r"></span><span class="docs-dot-y"></span><span class="docs-dot-g"></span></div>
31 <div class="docs-term-body">
32 <div class="docs-term-line"><span class="docs-prompt">$</span> echo "Hello" > hello.txt</div>
33 <div class="docs-term-line"><span class="docs-prompt">$</span> catena status</div>
34 <div class="docs-term-line" style="color:var(--accent)"> + hello.txt</div>
35 <div class="docs-term-line" style="color:var(--text3)">1 added, 0 modified, 0 deleted</div>
36 </div></div>
37 </div>
38 </div>
39
40 <div class="docs-step">
41 <div class="docs-step-num">4</div>
42 <div class="docs-step-content">
43 <h4>Push changes</h4>
44 <div class="docs-term"><div class="docs-term-bar"><span class="docs-dot-r"></span><span class="docs-dot-y"></span><span class="docs-dot-g"></span></div>
45 <div class="docs-term-body"><div class="docs-term-line"><span class="docs-prompt">$</span> catena sync up -m "Add hello"</div>
46 <div class="docs-term-line" style="color:var(--text3)">Synced up: patch abc123 created (1 ops)</div></div></div>
47 </div>
48 </div>
49
50 <div class="docs-step">
51 <div class="docs-step-num">5</div>
52 <div class="docs-step-content">
53 <h4>Propose &amp; Accept</h4>
54 <div class="docs-term"><div class="docs-term-bar"><span class="docs-dot-r"></span><span class="docs-dot-y"></span><span class="docs-dot-g"></span></div>
55 <div class="docs-term-body">
56 <div class="docs-term-line"><span class="docs-prompt">$</span> catena propose</div>
57 <div class="docs-term-line" style="color:#f59e0b">Patch abc123 proposed.</div>
58 <div class="docs-term-line"><span class="docs-prompt">$</span> catena accept abc123</div>
59 <div class="docs-term-line" style="color:var(--accent)">Patch abc123 accepted. Trunk updated.</div>
60 </div></div>
61 </div>
62 </div>
63 </div>
64</section>
65
+ src/Catena.Server/Components/Docs/DocsSync.razor
1<section id="sync">
2 <h3>Sync</h3>
3
4 <div class="docs-sync-diagram">
5 <div class="docs-sync-box">
6 <div class="docs-sync-label">Your Workspace</div>
7 <div class="docs-sync-icon">💻</div>
8 </div>
9 <div class="docs-sync-arrows">
10 <div class="docs-sync-arrow-up">sync up ↑</div>
11 <div class="docs-sync-arrow-down">↓ sync down</div>
12 </div>
13 <div class="docs-sync-box">
14 <div class="docs-sync-label">Server (Trunk)</div>
15 <div class="docs-sync-icon">☁️</div>
16 </div>
17 </div>
18
19 <div class="docs-term">
20 <div class="docs-term-bar"><span class="docs-dot-r"></span><span class="docs-dot-y"></span><span class="docs-dot-g"></span></div>
21 <div class="docs-term-body">
22 <div class="docs-term-line"><span class="docs-prompt">$</span> catena sync down <span class="docs-comment"># Server → Local (Pull)</span></div>
23 <div class="docs-term-line"><span class="docs-prompt">$</span> catena sync up -m "My fix" <span class="docs-comment"># Local → Server (Push)</span></div>
24 <div class="docs-term-line"><span class="docs-prompt">$</span> catena sync down --patch abc123 <span class="docs-comment"># Trunk + test a patch</span></div>
25 </div>
26 </div>
27
28 <div class="docs-callout">
29 <strong>Safety:</strong> <code>catena sync</code> without <code>down</code> asks for confirmation if you have local changes. With explicit <code>catena sync down</code> it doesn't ask — you know what you're doing.
30 </div>
31</section>
32
+ src/Catena.Server/Components/Docs/DocsWebUi.razor
1<section id="webui">
2 <h2>Web UI</h2>
3 <p>Available at <code>/app</code> after login.</p>
4 <div class="docs-ui-grid">
5 <div class="docs-ui-card">
6 <div class="docs-ui-card-icon">■</div>
7 <h4>Dashboard</h4>
8 <p>Overview: stats, activity feed, overlap count</p>
9 </div>
10 <div class="docs-ui-card">
11 <div class="docs-ui-card-icon">▶</div>
12 <h4>Patches</h4>
13 <p>Master-detail with diff, inline comments, overlaps</p>
14 </div>
15 <div class="docs-ui-card">
16 <div class="docs-ui-card-icon">⋮</div>
17 <h4>Timeline</h4>
18 <p>Patch chain graph with dependencies and overlaps</p>
19 </div>
20 <div class="docs-ui-card">
21 <div class="docs-ui-card-icon">☰</div>
22 <h4>Files</h4>
23 <p>Trunk file tree with code viewer</p>
24 </div>
25 <div class="docs-ui-card">
26 <div class="docs-ui-card-icon">●</div>
27 <h4>Releases</h4>
28 <p>Frozen snapshots with version tags</p>
29 </div>
30 <div class="docs-ui-card">
31 <div class="docs-ui-card-icon">⚙</div>
32 <h4>Settings</h4>
33 <p>Team, roles, API keys, webhooks</p>
34 </div>
35 </div>
36 <p>The UI updates <strong>live</strong> — when someone creates or accepts a patch via CLI or API, you see it instantly in the browser.</p>
37</section>
38
+ src/Catena.Server/Components/Docs/DocsWhat.razor
1<section id="what">
2 <h2>What is Catena?</h2>
3 <p>Catena is a patch-based version control system. Every change is a <strong>Patch</strong> — an atomic package of file operations. Patches are chained together in a linear sequence.</p>
4
5 <div class="docs-features">
6 <div class="docs-feature">
7 <div class="docs-feature-icon" style="color:var(--accent)">+</div>
8 <div><strong>Patches, not Commits</strong><br/><span class="docs-muted">Every change is a self-contained, reviewable package</span></div>
9 </div>
10 <div class="docs-feature">
11 <div class="docs-feature-icon" style="color:var(--accent)">│</div>
12 <div><strong>Trunk, not Branches</strong><br/><span class="docs-muted">One single truth, no diverging histories</span></div>
13 </div>
14 <div class="docs-feature">
15 <div class="docs-feature-icon" style="color:#ef4444">!</div>
16 <div><strong>Overlap Detection</strong><br/><span class="docs-muted">Conflicts are detected immediately, not at integration time</span></div>
17 </div>
18 <div class="docs-feature">
19 <div class="docs-feature-icon" style="color:#3b82f6">↕</div>
20 <div><strong>Online-first</strong><br/><span class="docs-muted">The server is the source of truth, sync is trivial</span></div>
21 </div>
22 <div class="docs-feature">
23 <div class="docs-feature-icon" style="color:#f59e0b">💬</div>
24 <div><strong>Inline Comments</strong><br/><span class="docs-muted">Comment directly on code lines in a patch</span></div>
25 </div>
26 </div>
27</section>
28
+ src/Catena.Server/Components/Layout/EmptyLayout.razor
1@inherits LayoutComponentBase
2
3@Body
4
+ src/Catena.Server/Components/Layout/MainLayout.razor
1@inherits LayoutComponentBase
2@using Microsoft.AspNetCore.Components.Authorization
3@using System.Security.Claims
4@inject ProjectStore ProjectStore
5@inject PatchStore PatchStore
6@inject NavigationManager Nav
7@inject AuthenticationStateProvider AuthState
8
9<div class="app-shell">
10 <div class="sidebar">
11 <a href="/" class="sidebar-brand">CATENA<span>.</span></a>
12
13 @if (_projects is not null && _projects.Count > 0)
14 {
15 <a href="/app" class="project-select">@(_activeProject?.Name ?? "Select project")</a>
16 }
17
18 @if (_activeProjectId is not null)
19 {
20 <div class="nav">
21 <a href="/app/projects/@_activeProjectId" class="nav-item @IsActive("dashboard")">
22 <span class="nav-icon">&#9632;</span> Dashboard
23 </a>
24 <a href="/app/projects/@_activeProjectId/patches" class="nav-item @IsActive("patches")">
25 <span class="nav-icon">&#9654;</span> Patches
26 @if (_overlapCount > 0)
27 {
28 <span class="nav-badge">@_overlapCount</span>
29 }
30 </a>
31 <a href="/app/projects/@_activeProjectId/timeline" class="nav-item @IsActive("timeline")">
32 <span class="nav-icon">&#8942;</span> Timeline
33 </a>
34 <a href="/app/projects/@_activeProjectId/files" class="nav-item @IsActive("files")">
35 <span class="nav-icon">&#9783;</span> Files
36 </a>
37 <a href="/app/projects/@_activeProjectId/releases" class="nav-item @IsActive("releases")">
38 <span class="nav-icon">&#9679;</span> Releases
39 </a>
40 <a href="/app/projects/@_activeProjectId/settings" class="nav-item @IsActive("settings")">
41 <span class="nav-icon">&#9881;</span> Settings
42 </a>
43 </div>
44 }
45
46 @if (_userName is not null)
47 {
48 <div class="sidebar-footer">
49 <div class="user-dot">@_userInitials</div>
50 <div style="flex:1">
51 <div style="font-size:12px;color:var(--text2)">@_userName</div>
52 <div style="font-size:10px;color:var(--text3)">@_userRole</div>
53 </div>
54 <a href="/auth/logout" style="font-size:10px;color:var(--text3);text-decoration:none;opacity:.6;padding:4px" title="Logout">&#x2715;</a>
55 </div>
56 }
57 else
58 {
59 <div class="sidebar-footer">
60 <a href="/login" style="color:var(--text2);text-decoration:none;font-size:12px">Sign in</a>
61 </div>
62 }
63 </div>
64
65 <div class="main-content">
66 @Body
67 </div>
68</div>
69
70@code {
71 private List<Catena.Shared.Models.Project>? _projects;
72 private Catena.Shared.Models.Project? _activeProject;
73 private string? _activeProjectId;
74 private string? _userName;
75 private string? _userInitials;
76 private string? _userRole;
77 private int _overlapCount;
78
79 protected override async Task OnInitializedAsync()
80 {
81 _projects = await ProjectStore.ListAsync();
82
83 var uri = Nav.ToAbsoluteUri(Nav.Uri);
84 var path = uri.AbsolutePath;
85 if (path.StartsWith("/app/projects/") && path.Length > 15)
86 {
87 var rest = path["/app/projects/".Length..];
88 _activeProjectId = rest.Contains('/') ? rest[..rest.IndexOf('/')] : rest;
89 _activeProject = await ProjectStore.GetAsync(_activeProjectId);
90 }
91 else if (_projects.Count > 0)
92 {
93 _activeProject = _projects[0];
94 _activeProjectId = _activeProject.Id;
95 }
96
97 if (_activeProjectId is not null)
98 {
99 var proposed = await PatchStore.ListAsync(_activeProjectId, maturityFilter: Catena.Shared.Models.Maturity.Proposed);
100 var overlaps = 0;
101 for (int i = 0; i < proposed.Count; i++)
102 for (int j = i + 1; j < proposed.Count; j++)
103 {
104 var report = Catena.Shared.Overlap.OverlapDetector.Detect(proposed[i], proposed[j]);
105 if (report.HasConflicts) overlaps++;
106 }
107 _overlapCount = overlaps;
108 }
109
110 var authState = await AuthState.GetAuthenticationStateAsync();
111 if (authState.User.Identity?.IsAuthenticated == true)
112 {
113 _userName = authState.User.FindFirstValue(ClaimTypes.Name);
114 _userRole = authState.User.FindFirstValue(ClaimTypes.Role);
115 _userInitials = _userName?.Length >= 2
116 ? _userName[..1].ToUpper() + _userName[1..2].ToUpper()
117 : _userName?.ToUpper();
118 }
119 }
120
121 private string IsActive(string page)
122 {
123 var path = Nav.ToAbsoluteUri(Nav.Uri).AbsolutePath;
124 if (page == "dashboard" && path == $"/app/projects/{_activeProjectId}") return "active";
125 return path.Contains($"/{page}") ? "active" : "";
126 }
127}
128
+ src/Catena.Server/Components/Layout/NavMenu.razor
1<ul class="nav-menu">
2 <li><NavLink href="/app" Match="NavLinkMatch.All">Projects</NavLink></li>
3</ul>
4
5<AuthorizeView Roles="Admin">
6 <Authorized>
7 <hr />
8 <ul class="nav-menu">
9 <li><NavLink href="/app/admin/users">Users</NavLink></li>
10 </ul>
11 </Authorized>
12</AuthorizeView>
13
14<hr />
15<ul class="nav-menu">
16 <li><a href="/docs" class="nav-item" style="font-size:11px;color:var(--text3)">How it Works</a></li>
17</ul>
18
+ src/Catena.Server/Components/Pages/AppIndex.razor
1@page "/app"
2@layout Catena.Server.Components.Layout.MainLayout
3@inject ProjectStore ProjectStore
4@inject NavigationManager Nav
5
6@if (!_empty)
7{
8 <div class="topbar">
9 <span class="topbar-title">Loading...</span>
10 </div>
11}
12else
13{
14 <div class="topbar">
15 <span class="topbar-title">Welcome</span>
16 </div>
17 <div class="dashboard-content">
18 <div style="text-align:center;padding:80px 24px">
19 <div style="font-size:24px;font-weight:500;margin-bottom:12px">No projects yet</div>
20 <div style="font-size:13px;color:var(--text3);margin-bottom:24px">Create your first project via the CLI:</div>
21 <div style="font-family:var(--mono);font-size:12px;color:var(--accent);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;display:inline-block">
22 catena init --new "My Project" --api-key &lt;key&gt;
23 </div>
24 </div>
25 </div>
26}
27
28@code {
29 private bool _empty;
30
31 protected override async Task OnInitializedAsync()
32 {
33 var projects = await ProjectStore.ListAsync();
34 if (projects.Count > 0)
35 Nav.NavigateTo($"/app/projects/{projects[0].Id}", replace: true);
36 else
37 _empty = true;
38 }
39}
40
+ src/Catena.Server/Components/Pages/Docs.razor
1@page "/docs"
2@page "/how-it-works"
3@layout Catena.Server.Components.Layout.EmptyLayout
4@using Catena.Server.Components.Docs
5
6<div class="docs">
7 <nav class="docs-toc">
8 <div class="docs-toc-brand"><a href="/">CATENA<span>.</span></a></div>
9 <a href="#what" class="docs-toc-item">What is Catena?</a>
10 <a href="#concepts" class="docs-toc-item">Core Concepts</a>
11 <a href="#maturity" class="docs-toc-item docs-toc-sub">Maturity Flow</a>
12 <a href="#overlaps" class="docs-toc-item docs-toc-sub">Overlaps</a>
13 <a href="#dependencies" class="docs-toc-item docs-toc-sub">Dependencies</a>
14 <a href="#sync" class="docs-toc-item docs-toc-sub">Sync</a>
15 <a href="#roles" class="docs-toc-item">Roles</a>
16 <a href="#start" class="docs-toc-item">Getting Started</a>
17 <a href="#flows" class="docs-toc-item">Workflows</a>
18 <a href="#daily" class="docs-toc-item docs-toc-sub">Daily Workflow</a>
19 <a href="#conflict" class="docs-toc-item docs-toc-sub">Resolving Conflicts</a>
20 <a href="#dep-flow" class="docs-toc-item docs-toc-sub">Building on Patches</a>
21 <a href="#hotfix" class="docs-toc-item docs-toc-sub">Hotfix</a>
22 <a href="#cli" class="docs-toc-item">CLI Reference</a>
23 <a href="#webui" class="docs-toc-item">Web UI</a>
24 <a href="#design" class="docs-toc-item">Design Decisions</a>
25 </nav>
26
27 <main class="docs-content">
28 <DocsHero />
29 <DocsWhat />
30 <DocsConcepts />
31 <DocsMaturity />
32 <DocsOverlaps />
33 <DocsDependencies />
34 <DocsSync />
35 <DocsRoles />
36 <DocsStart />
37 <DocsFlows />
38 <DocsCli />
39 <DocsWebUi />
40 <DocsDesign />
41 </main>
42</div>
43
44<style>
45.docs{display:flex;min-height:100vh;background:var(--bg)}
46.docs-toc{width:240px;position:sticky;top:0;height:100vh;overflow-y:auto;border-right:1px solid var(--border);padding:20px 0;flex-shrink:0}
47.docs-toc-brand{padding:0 20px 16px;border-bottom:1px solid var(--border);margin-bottom:12px}
48.docs-toc-brand a{font-size:15px;font-weight:600;letter-spacing:1px;color:var(--text);text-decoration:none}
49.docs-toc-brand a span{color:var(--accent)}
50.docs-toc-item{display:block;padding:6px 20px;font-size:13px;color:var(--text2);text-decoration:none;transition:color .1s}
51.docs-toc-item:hover{color:var(--text)}
52.docs-toc-sub{padding-left:32px;font-size:12px;color:var(--text3)}
53.docs-content{flex:1;max-width:800px;margin:0 auto;padding:40px 40px 80px}
54.docs-hero{margin-bottom:48px}
55.docs-hero h1{font-size:28px;font-weight:500;margin-bottom:8px}
56.docs-lead{font-size:15px;color:var(--text2)}
57section{margin-bottom:40px}
58h2{font-size:20px;font-weight:500;margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border)}
59h3{font-size:16px;font-weight:500;margin:24px 0 12px;color:var(--text2)}
60h4{font-size:14px;font-weight:500;margin-bottom:8px}
61p{font-size:14px;color:var(--text2);line-height:1.7;margin-bottom:12px}
62code{font-family:var(--mono);font-size:12px;color:var(--accent);background:var(--surface2);padding:2px 6px;border-radius:3px}
63.docs-muted{font-size:12px;color:var(--text3)}
64.docs-features{display:flex;flex-direction:column;gap:12px;margin:16px 0}
65.docs-feature{display:flex;align-items:flex-start;gap:12px;padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:8px}
66.docs-feature-icon{width:28px;height:28px;border-radius:6px;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:14px;flex-shrink:0}
67.docs-ops{display:flex;gap:8px;margin:12px 0}
68.docs-op{font-family:var(--mono);font-size:11px;padding:4px 10px;border-radius:4px}
69.docs-op-insert{background:#052e16;color:var(--accent)}
70.docs-op-modify{background:#451a03;color:#f59e0b}
71.docs-op-delete{background:#450a0a;color:#ef4444}
72.docs-op-rename{background:#172554;color:#3b82f6}
73.docs-flow{display:flex;align-items:center;gap:0;margin:20px 0 8px;flex-wrap:wrap;justify-content:center}
74.docs-flow-node{padding:8px 14px;border-radius:6px;font-size:12px;font-family:var(--mono);font-weight:500}
75.docs-flow-draft{background:var(--surface2);color:var(--text3)}
76.docs-flow-synced{background:#172554;color:#3b82f6}
77.docs-flow-shared{background:#172554;color:#3b82f6;border:1px solid #3b82f6}
78.docs-flow-proposed{background:#451a03;color:#f59e0b}
79.docs-flow-accepted{background:#052e16;color:var(--accent)}
80.docs-flow-arrow{color:var(--text3);padding:0 6px;font-size:16px}
81.docs-flow-withdraw{text-align:center;margin:0 0 16px}
82.docs-badge{font-size:10px;font-family:var(--mono);padding:3px 8px;border-radius:4px;font-weight:500;white-space:nowrap}
83.docs-badge-draft{background:var(--surface2);color:var(--text3)}
84.docs-badge-synced{background:#172554;color:#3b82f6}
85.docs-badge-shared{background:#172554;color:#3b82f6}
86.docs-badge-proposed{background:#451a03;color:#f59e0b}
87.docs-badge-accepted{background:#052e16;color:var(--accent)}
88.docs-stages{display:flex;flex-direction:column;gap:8px}
89.docs-stage{font-size:13px;color:var(--text2);display:flex;align-items:center;gap:10px}
90.docs-overlap-diagram{display:flex;align-items:center;justify-content:center;gap:0;margin:20px 0}
91.docs-overlap-patch{display:flex;align-items:center;gap:8px;padding:8px 14px;background:var(--surface);border:1px solid;border-radius:8px;font-size:12px;font-weight:500}
92.docs-overlap-dot{width:10px;height:10px;border-radius:50%;border:2px solid;flex-shrink:0}
93.docs-overlap-line{display:flex;flex-direction:column;align-items:center;gap:0;padding:0 4px}
94.docs-overlap-hline{width:40px;height:2px;background:#ef4444}
95.docs-overlap-badge{font-size:9px;font-family:var(--mono);padding:2px 8px;border-radius:8px;background:#450a0a;color:#ef4444;border:1px solid #991b1b;margin-top:4px}
96.docs-overlap-results{display:flex;flex-direction:column;gap:6px;margin-top:12px}
97.docs-overlap-result{font-size:13px;color:var(--text2);display:flex;gap:8px;align-items:center}
98.docs-dep-chain{display:flex;flex-direction:column;align-items:center;gap:0;margin:20px 0}
99.docs-dep-node{padding:8px 16px;border:1px solid;border-radius:8px;font-size:12px;font-weight:500;background:var(--surface)}
100.docs-dep-trunk{background:#052e16;border-color:#166534}
101.docs-dep-line{width:2px;height:16px;background:#3b82f6;opacity:.5}
102.docs-dep-multi{display:flex;gap:12px}
103.docs-sync-diagram{display:flex;align-items:center;justify-content:center;gap:24px;margin:20px 0}
104.docs-sync-box{text-align:center;padding:16px 24px;background:var(--surface);border:1px solid var(--border);border-radius:8px}
105.docs-sync-label{font-size:11px;color:var(--text3);margin-bottom:4px}
106.docs-sync-icon{font-size:24px}
107.docs-sync-arrows{display:flex;flex-direction:column;gap:4px;font-family:var(--mono);font-size:11px}
108.docs-sync-arrow-up{color:var(--accent)}
109.docs-sync-arrow-down{color:#3b82f6}
110.docs-term{background:#0a0a0c;border:1px solid var(--border);border-radius:8px;overflow:hidden;margin:12px 0}
111.docs-term-bar{display:flex;gap:6px;padding:8px 12px;border-bottom:1px solid var(--border)}
112.docs-dot-r{width:10px;height:10px;border-radius:50%;background:#ef4444}
113.docs-dot-y{width:10px;height:10px;border-radius:50%;background:#f59e0b}
114.docs-dot-g{width:10px;height:10px;border-radius:50%;background:var(--accent)}
115.docs-term-body{padding:12px 14px;font-family:var(--mono);font-size:12px;line-height:1.8}
116.docs-term-line{color:var(--text2)}
117.docs-prompt{color:var(--accent);margin-right:6px}
118.docs-comment{color:var(--text3)}
119.docs-callout{background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:4px;padding:12px 16px;font-size:13px;color:var(--text2);margin:12px 0}
120.docs-roles{display:flex;flex-direction:column;gap:8px}
121.docs-role{display:flex;align-items:center;gap:12px;font-size:13px;color:var(--text2)}
122.docs-steps{display:flex;flex-direction:column;gap:24px}
123.docs-step{display:flex;gap:16px}
124.docs-step-num{width:32px;height:32px;border-radius:50%;background:var(--accent-dim);border:1.5px solid #166534;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;color:var(--accent);flex-shrink:0}
125.docs-step-content{flex:1}
126.docs-flow-vertical{display:flex;flex-direction:column;align-items:center;gap:0;margin:16px 0}
127.docs-fv-step{display:flex;align-items:center;gap:12px;padding:8px 16px;background:var(--surface);border:1px solid var(--border);border-radius:6px;font-size:13px}
128.docs-fv-cmd{font-family:var(--mono);color:var(--accent);font-size:12px}
129.docs-fv-action{color:var(--text2);font-style:italic}
130.docs-fv-arrow{color:var(--text3);font-size:14px;padding:2px 0}
131.docs-cli-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin:12px 0}
132.docs-cli-cmd{display:flex;flex-direction:column;gap:2px;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:6px}
133.docs-cli-cmd code{background:transparent;padding:0;font-size:12px}
134.docs-cli-cmd span{font-size:11px;color:var(--text3)}
135.docs-ui-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:16px 0}
136.docs-ui-card{padding:16px;background:var(--surface);border:1px solid var(--border);border-radius:8px}
137.docs-ui-card-icon{font-size:16px;margin-bottom:8px;opacity:.5}
138.docs-ui-card h4{font-size:13px;margin-bottom:4px}
139.docs-ui-card p{font-size:11px;color:var(--text3);margin:0}
140.docs-footer{margin-top:60px;padding-top:20px;border-top:1px solid var(--border);display:flex;justify-content:space-between;font-size:12px;color:var(--text3)}
141.docs-footer a{color:var(--accent);text-decoration:none}
142</style>
143
+ src/Catena.Server/Components/Pages/Files.razor
1@page "/app/projects/{ProjectId}/files"
2@layout Catena.Server.Components.Layout.MainLayout
3@inject TrunkState TrunkState
4@inject PatchStore PatchStore
5
6<div class="topbar">
7 <span class="topbar-title">Files</span>
8 <span class="topbar-sub">trunk-head · @_fileCount files</span>
9</div>
10
11<div class="files-layout">
12 <div class="file-tree">
13 @foreach (var path in _filePaths)
14 {
15 var depth = path.Count(c => c == '/');
16 var name = path.Contains('/') ? path[(path.LastIndexOf('/') + 1)..] : path;
17 var dir = path.Contains('/') ? path[..path.LastIndexOf('/')] : "";
18
19 @if (dir != _lastDir && dir != "")
20 {
21 <div class="file-item folder" style="padding-left:20px">@dir/</div>
22 _lastDir = dir;
23 }
24
25 <div class="file-item @(_selectedFile == path ? "selected" : "")"
26 style="padding-left:@(20 + depth * 20)px"
27 @onclick="() => SelectFile(path)">
28 @name
29 </div>
30 }
31 </div>
32
33 <div class="file-viewer">
34 @if (_selectedFile is not null)
35 {
36 <div class="file-viewer-header">@_selectedFile</div>
37 <div class="file-viewer-meta">
38 @if (_lastPatchAuthor is not null)
39 {
40 <span>Last changed by: <span style="color:var(--proposed)">@_lastPatchDesc</span> (@_lastPatchAuthor)</span>
41 }
42 </div>
43 <div class="file-code">@((MarkupString)_fileContent)</div>
44 }
45 else
46 {
47 <div style="color:var(--text3);font-size:14px;padding:20px">Select a file to view contents</div>
48 }
49 </div>
50</div>
51
52@code {
53 [Parameter] public string ProjectId { get; set; } = "";
54
55 private int _fileCount;
56 private List<string> _filePaths = [];
57 private string? _selectedFile;
58 private string _fileContent = "";
59 private string? _lastPatchAuthor;
60 private string? _lastPatchDesc;
61 private string _lastDir = "";
62
63 protected override async Task OnInitializedAsync()
64 {
65 var files = await TrunkState.GetFilesAsync(ProjectId);
66 _fileCount = files.Count;
67 _filePaths = files.Keys.OrderBy(f => f).ToList();
68 }
69
70 private async Task SelectFile(string path)
71 {
72 _selectedFile = path;
73 _fileContent = "";
74 _lastPatchAuthor = null;
75 _lastPatchDesc = null;
76
77 var blobIndex = await TrunkState.GetBlobIndexAsync(ProjectId);
78 if (blobIndex.TryGetValue(path, out var blobRef))
79 {
80 var blob = await PatchStore.GetBlobAsync(ProjectId, blobRef.PatchId, blobRef.OpIndex);
81 if (blob is not null)
82 {
83 try
84 {
85 var text = System.Text.Encoding.UTF8.GetString(blob);
86 var lines = text.Split('\n');
87 var sb = new System.Text.StringBuilder();
88 for (int i = 0; i < lines.Length; i++)
89 {
90 sb.Append($"<span class=\"ln\">{i + 1,3}</span>");
91 sb.AppendLine(System.Net.WebUtility.HtmlEncode(lines[i]));
92 }
93 _fileContent = sb.ToString();
94 }
95 catch { _fileContent = $"(binary file, {blob.Length} bytes)"; }
96 }
97
98 var patch = await PatchStore.GetAsync(ProjectId, blobRef.PatchId);
99 if (patch is not null)
100 {
101 _lastPatchAuthor = patch.Author;
102 _lastPatchDesc = patch.Description;
103 }
104 }
105 }
106}
107
+ src/Catena.Server/Components/Pages/History.razor
1@page "/app/projects/{ProjectId}/history"
2@layout Catena.Server.Components.Layout.MainLayout
3@inject PatchStore PatchStore
4
5<h1>History</h1>
6
7<div class="filters">
8 <input type="text" @bind="_fileFilter" placeholder="Filter by file..." />
9 <input type="text" @bind="_authorFilter" placeholder="Filter by author..." />
10 <button class="btn" @onclick="LoadHistory">Filter</button>
11</div>
12
13@if (_patches is null)
14{
15 <p>Loading...</p>
16}
17else if (_patches.Count == 0)
18{
19 <p>No patches in history.</p>
20}
21else
22{
23 <table class="data-table">
24 <thead>
25 <tr><th>ID</th><th>Author</th><th>Description</th><th>Ops</th><th>Time</th></tr>
26 </thead>
27 <tbody>
28 @foreach (var p in _patches)
29 {
30 <tr>
31 <td><a href="/app/projects/@ProjectId/patches/@p.Id"><code>@p.Id[..8]</code></a></td>
32 <td>@p.Author</td>
33 <td>@p.Description</td>
34 <td>@p.Ops.Count</td>
35 <td>@p.Timestamp.ToString("yyyy-MM-dd HH:mm")</td>
36 </tr>
37 }
38 </tbody>
39 </table>
40}
41
42@code {
43 [Parameter] public string ProjectId { get; set; } = "";
44
45 private List<Patch>? _patches;
46 private string _fileFilter = "";
47 private string _authorFilter = "";
48
49 protected override async Task OnInitializedAsync()
50 {
51 await LoadHistory();
52 }
53
54 private async Task LoadHistory()
55 {
56 var all = await PatchStore.ListAsync(ProjectId, maturityFilter: Maturity.Accepted,
57 authorFilter: string.IsNullOrWhiteSpace(_authorFilter) ? null : _authorFilter);
58
59 if (!string.IsNullOrWhiteSpace(_fileFilter))
60 {
61 all = all.Where(p => p.Ops.Any(op => op.File.Contains(_fileFilter, StringComparison.OrdinalIgnoreCase))).ToList();
62 }
63
64 _patches = all;
65 }
66}
67
+ src/Catena.Server/Components/Pages/Landing.razor
1@page "/"
2@layout Catena.Server.Components.Layout.EmptyLayout
3@inject ProjectStore ProjectStore
4@using Microsoft.AspNetCore.Components.Authorization
5@inject AuthenticationStateProvider AuthState
6
7<div class="landing">
8 <header class="landing-hero">
9 <div class="landing-brand">CATENA<span class="dot">.</span></div>
10 <p class="landing-tagline">Patch-based version control for teams that ship.</p>
11 <p class="landing-sub">No branches. No merges. Just patches, applied in order.</p>
12 <div class="landing-actions">
13 @if (_isAuthenticated)
14 {
15 <a href="/app" class="btn-primary">Open Dashboard</a>
16 }
17 else
18 {
19 <a href="/login" class="btn-primary">Sign In</a>
20 }
21 <a href="/docs" class="btn-primary" style="background:transparent;border:1px solid var(--border);color:var(--text2)">How it Works</a>
22 </div>
23 </header>
24
25 @if (_publicProjects is not null && _publicProjects.Count > 0)
26 {
27 <section class="landing-projects">
28 <h2>Public Projects</h2>
29 <div class="project-grid">
30 @foreach (var p in _publicProjects)
31 {
32 <a href="/app/projects/@p.Id" class="project-card">
33 <div class="project-card-name">@p.Name</div>
34 <div class="project-card-meta">
35 <code>@p.Id</code>
36 <span>@p.CreatedAt.ToString("MMM dd, yyyy")</span>
37 </div>
38 </a>
39 }
40 </div>
41 </section>
42 }
43
44 <section class="landing-features">
45 <div class="feature">
46 <div class="feature-icon">+</div>
47 <h3>Patch-Chain</h3>
48 <p>Immutable, append-only patches. Every change is a first-class object with author, dependencies, and targets.</p>
49 </div>
50 <div class="feature">
51 <div class="feature-icon">~</div>
52 <h3>Overlap Detection</h3>
53 <p>Deterministic conflict detection before merge. No surprises, no broken builds. Auto-apply when safe, block when not.</p>
54 </div>
55 <div class="feature">
56 <div class="feature-icon">&gt;</div>
57 <h3>Views, not Branches</h3>
58 <p>Trunk is the only truth. Proposed patches are candidates, not parallel universes. Accept what's ready, revert what's not.</p>
59 </div>
60 </section>
61
62 <footer class="landing-footer">
63 <span>Catena VCS</span>
64 </footer>
65</div>
66
67@code {
68 private List<Catena.Shared.Models.Project>? _publicProjects;
69 private bool _isAuthenticated;
70
71 protected override async Task OnInitializedAsync()
72 {
73 var projects = await ProjectStore.ListAsync();
74 _publicProjects = projects.Where(p => p.IsPublic).ToList();
75
76 var authState = await AuthState.GetAuthenticationStateAsync();
77 _isAuthenticated = authState.User.Identity?.IsAuthenticated == true;
78 }
79}
80
+ src/Catena.Server/Components/Pages/Login.razor
1@page "/login"
2@layout Catena.Server.Components.Layout.EmptyLayout
3
4<div class="login-fullscreen">
5 <div class="login-left">
6 <a href="/" class="login-hero-brand">CATENA<span>.</span></a>
7 <div class="login-hero-text">
8 <p>Patch-based version control</p>
9 <p>for teams that ship.</p>
10 </div>
11 <div class="login-hero-features">
12 <div class="login-feature"><span class="login-feature-icon">+</span> Immutable patch chains</div>
13 <div class="login-feature"><span class="login-feature-icon">~</span> Deterministic overlap detection</div>
14 <div class="login-feature"><span class="login-feature-icon">&gt;</span> No branches, no merges</div>
15 </div>
16 </div>
17
18 <div class="login-right">
19 <div class="login-terminal">
20 <div class="login-term-bar">
21 <div class="login-term-dot" style="background:var(--danger)"></div>
22 <div class="login-term-dot" style="background:var(--proposed)"></div>
23 <div class="login-term-dot" style="background:var(--accent)"></div>
24 <span style="flex:1"></span>
25 <span style="font-size:10px;color:var(--text3);font-family:var(--mono)">authenticate</span>
26 </div>
27
28 <div class="login-term-body">
29 <div class="login-term-line"><span class="login-prompt">$</span> <span style="color:var(--text2)">catena auth login</span></div>
30
31 @if (_hasError)
32 {
33 <div class="login-term-line"><span style="color:var(--danger)">error: invalid api key</span></div>
34 <div class="login-term-line"><span class="login-prompt">$</span> <span style="color:var(--text2)">catena auth login --retry</span></div>
35 }
36 else
37 {
38 <div class="login-term-line"><span style="color:var(--accent)">ready.</span> <span style="color:var(--text3)">paste your api key below</span></div>
39 }
40
41 <form method="post" action="/auth/web-login" class="login-term-form">
42 <div class="login-input-row">
43 <span class="login-prompt">$</span>
44 <input type="password" name="apiKey" placeholder="cat_••••••••••••••••" required autocomplete="off" />
45 </div>
46 <button type="submit">
47 authenticate <span style="opacity:.5;margin-left:8px">↵</span>
48 </button>
49 </form>
50 </div>
51 </div>
52
53 <div class="login-hint">
54 Don't have a key? Ask your team admin.
55 </div>
56 </div>
57</div>
58
59<style>
60.login-fullscreen {
61 display:flex;height:100vh;overflow:hidden;
62}
63
64.login-left {
65 flex:1;display:flex;flex-direction:column;justify-content:center;
66 padding:60px;
67 background:radial-gradient(ellipse at 30% 50%,rgba(34,197,94,.04) 0%,transparent 70%);
68}
69
70.login-hero-brand {
71 font-size:36px;font-weight:700;letter-spacing:-1.5px;
72 color:var(--text);text-decoration:none;margin-bottom:24px;display:block;
73}
74.login-hero-brand span { color:var(--accent) }
75
76.login-hero-text {
77 font-size:20px;font-weight:300;color:var(--text2);line-height:1.5;margin-bottom:40px;
78}
79
80.login-hero-features {
81 display:flex;flex-direction:column;gap:12px;
82}
83
84.login-feature {
85 font-size:13px;color:var(--text3);display:flex;align-items:center;gap:10px;
86}
87
88.login-feature-icon {
89 width:28px;height:28px;border-radius:6px;
90 background:var(--accent-dim);border:1px solid #166534;
91 display:flex;align-items:center;justify-content:center;
92 font-family:var(--mono);font-size:14px;color:var(--accent);
93}
94
95.login-right {
96 width:480px;display:flex;flex-direction:column;justify-content:center;
97 padding:60px;border-left:1px solid var(--border);
98 background:var(--surface);
99}
100
101.login-terminal {
102 background:#0a0a0c;border:1px solid var(--border);border-radius:10px;overflow:hidden;
103}
104
105.login-term-bar {
106 display:flex;align-items:center;gap:6px;
107 padding:10px 14px;border-bottom:1px solid var(--border);
108}
109
110.login-term-dot { width:10px;height:10px;border-radius:50% }
111
112.login-term-body {
113 padding:16px;font-family:var(--mono);font-size:12px;line-height:1.8;
114}
115
116.login-term-line { margin-bottom:2px; }
117.login-prompt { color:var(--accent);margin-right:6px }
118
119.login-term-form {
120 margin-top:12px;display:flex;flex-direction:column;gap:10px;max-width:100%;
121}
122
123.login-input-row {
124 display:flex;align-items:center;gap:0;
125 background:var(--surface2);border:1px solid var(--border);border-radius:6px;
126 padding:0 12px;
127}
128
129.login-input-row input {
130 flex:1;background:transparent;border:none;outline:none;
131 color:var(--accent);font-family:var(--mono);font-size:12px;
132 padding:10px 8px;
133}
134
135.login-input-row input::placeholder { color:var(--text3);opacity:.6 }
136
137.login-term-form button {
138 background:var(--accent-dim);color:var(--accent);border:1px solid #166534;
139 border-radius:6px;padding:8px 16px;cursor:pointer;
140 font-family:var(--mono);font-size:12px;font-weight:500;
141 transition:background .15s;
142}
143
144.login-term-form button:hover { background:#0a3d1a }
145
146.login-hint {
147 margin-top:20px;font-size:11px;color:var(--text3);line-height:1.5;
148}
149</style>
150
151@code {
152 private bool _hasError;
153
154 [SupplyParameterFromQuery(Name = "error")]
155 public string? Error { get; set; }
156
157 protected override void OnInitialized()
158 {
159 _hasError = Error is not null;
160 }
161}
162
+ src/Catena.Server/Components/Pages/Overlaps.razor
1@page "/app/projects/{ProjectId}/overlaps"
2@layout Catena.Server.Components.Layout.MainLayout
3@using Catena.Shared.Overlap
4@inject PatchStore PatchStore
5
6<h1>Overlaps</h1>
7
8@if (_reports is null)
9{
10 <p>Loading...</p>
11}
12else if (_reports.Count == 0)
13{
14 <p>No overlaps between proposed patches.</p>
15}
16else
17{
18 <table class="data-table">
19 <thead>
20 <tr><th>Patch A</th><th>Patch B</th><th>Conflicts</th><th>Auto-Apply</th><th>Deduplicated</th></tr>
21 </thead>
22 <tbody>
23 @foreach (var r in _reports)
24 {
25 <tr class="@(r.HasConflicts ? "row-conflict" : "")">
26 <td><a href="/app/projects/@ProjectId/patches/@r.PatchIdA"><code>@r.PatchIdA[..8]</code></a></td>
27 <td><a href="/app/projects/@ProjectId/patches/@r.PatchIdB"><code>@r.PatchIdB[..8]</code></a></td>
28 <td class="@(r.Conflicts.Any() ? "text-red" : "")">@r.Conflicts.Count()</td>
29 <td>@r.AutoApplies.Count()</td>
30 <td>@r.Deduplications.Count()</td>
31 </tr>
32 }
33 </tbody>
34 </table>
35}
36
37@code {
38 [Parameter] public string ProjectId { get; set; } = "";
39
40 private List<PatchOverlapReport>? _reports;
41
42 protected override async Task OnInitializedAsync()
43 {
44 var proposed = await PatchStore.ListAsync(ProjectId, maturityFilter: Maturity.Proposed);
45 _reports = [];
46
47 for (int i = 0; i < proposed.Count; i++)
48 {
49 for (int j = i + 1; j < proposed.Count; j++)
50 {
51 var report = OverlapDetector.Detect(proposed[i], proposed[j]);
52 if (report.Results.Count > 0)
53 _reports.Add(report);
54 }
55 }
56 }
57}
58
+ src/Catena.Server/Components/Pages/Patches.razor
1@page "/app/projects/{ProjectId}/patches"
2@layout Catena.Server.Components.Layout.MainLayout
3@implements IDisposable
4@inject PatchStore PatchStore
5@inject ReviewStore ReviewStore
6@inject TrunkState TrunkState
7@using Catena.Shared.Overlap
8@using Microsoft.AspNetCore.Components.Authorization
9@using System.Security.Claims
10@inject AuthenticationStateProvider AuthState
11
12<div class="topbar">
13 <span class="topbar-title">Patches</span>
14 <span class="topbar-sub">@_patches?.Count active</span>
15</div>
16
17<div class="patches-layout">
18 <!-- LEFT: Patch list -->
19 <div class="patch-list">
20 <div class="patch-filters">
21 @foreach (var f in _filters)
22 {
23 <button class="filter-pill @(f == _activeFilter ? "active" : "")" @onclick="() => SetFilter(f)">@f</button>
24 }
25 </div>
26
27 @if (_filteredPatches is not null)
28 {
29 @foreach (var p in _filteredPatches)
30 {
31 <div class="patch-item @(p.Id == _selectedId ? "selected" : "")" @onclick="() => SelectPatch(p.Id)">
32 <div class="patch-header">
33 <span class="patch-name">@p.Description</span>
34 <span class="patch-status @StatusClass(p.Maturity)">@StatusText(p.Maturity)</span>
35 </div>
36 <div class="patch-meta">@p.Author · @p.Ops.Count files · @PatchTime(p)</div>
37 @{var overlaps = GetOverlapsFor(p.Id);}
38 @if (overlaps.Count > 0)
39 {
40 <div class="patch-overlap">@overlaps.Count overlap@(overlaps.Count > 1 ? "s" : "") with @string.Join(", ", overlaps)</div>
41 }
42 </div>
43 }
44 }
45 </div>
46
47 <!-- RIGHT: Patch detail -->
48 <div class="patch-detail">
49 @if (_selected is not null)
50 {
51 <div class="patch-detail-header">
52 <div style="display:flex;justify-content:space-between;align-items:flex-start">
53 <div>
54 <div class="pd-title">@_selected.Description</div>
55 <div class="pd-meta">@_selected.Author · @StatusText(_selected.Maturity) @FormatTimeAgo(_selected.Timestamp) · base: trunk-head</div>
56 </div>
57 @if (!_isReadonly)
58 {
59 <div style="display:flex;gap:6px">
60 @if (_selected.Maturity == Maturity.Proposed && _isMaintainer)
61 {
62 <button class="btn btn-accent" @onclick="AcceptPatch">Accept</button>
63 }
64 @if (_selected.Maturity == Maturity.Proposed)
65 {
66 <button class="btn" @onclick="WithdrawPatch">Withdraw</button>
67 }
68 @if (_selected.Maturity is Maturity.DraftSynced or Maturity.DraftShared)
69 {
70 <button class="btn btn-accent" @onclick="ProposePatch">Propose</button>
71 }
72 </div>
73 }
74 </div>
75 <div class="pd-tags">
76 @{var selOverlaps = GetOverlapsFor(_selected.Id);}
77 @if (selOverlaps.Count > 0)
78 {
79 <span class="pd-tag" style="background:var(--danger-dim);color:var(--danger)">@selOverlaps.Count overlap: @string.Join(", ", selOverlaps)</span>
80 }
81 <span class="pd-tag" style="background:var(--surface2);color:var(--text3)">@_selected.Ops.Count files changed</span>
82 @{var la = _selected.Ops.Sum(o => o.LinesAdded); var ld = _selected.Ops.Sum(o => o.LinesDeleted);}
83 <span class="pd-tag" style="background:var(--surface2);color:var(--text3)">+@la -@ld lines</span>
84 </div>
85 </div>
86
87 <!-- TABS -->
88 <div class="patch-tabs">
89 <div class="patch-tab @(_activeTab == "diff" ? "active" : "")" @onclick='() => _activeTab = "diff"'>Diff</div>
90 <div class="patch-tab @(_activeTab == "files" ? "active" : "")" @onclick='() => _activeTab = "files"'>Files (@_selected.Ops.Count)</div>
91 <div class="patch-tab @(_activeTab == "review" ? "active" : "")" @onclick='() => _activeTab = "review"'>Review (@_reviews.Count)</div>
92 <div class="patch-tab @(_activeTab == "overlaps" ? "active" : "")" style="@(selOverlaps.Count > 0 ? "color:var(--danger)" : "")" @onclick='() => _activeTab = "overlaps"'>Overlaps (@selOverlaps.Count)</div>
93 </div>
94
95 <div class="patch-tab-content">
96 @* ===== DIFF TAB ===== *@
97 @if (_activeTab == "diff")
98 {
99 @foreach (var op in _selected.Ops)
100 {
101 var currentFile = op.File;
102 <div class="diff-file">
103 <div class="diff-file-header">
104 @OpPrefix(op.Type) @op.File
105 @if (op.NewPath is not null) { <span> &rarr; @op.NewPath</span> }
106 </div>
107 <div class="diff-lines">
108 @if (op.Type == OpType.Insert && _blobContents.TryGetValue(op.File, out var content))
109 {
110 var lines = content.Split('\n');
111 @for (int i = 0; i < lines.Length; i++)
112 {
113 var lineNum = i + 1;
114 var cf = currentFile;
115 var ln = lineNum;
116 <div class="diff-line add">
117 @if (_isReadonly) { <span class="ln">@ln</span> } else { <span class="ln ln-clickable" @onclick="() => OpenLineComment(cf, ln)" title="Comment on line @ln">@ln</span> }@lines[i]
118 </div>
119 @foreach (var c in _reviews.Where(r => r.FilePath == cf && r.LineNumber == ln && !string.IsNullOrEmpty(r.Comment)))
120 {
121 <div class="diff-comment">
122 <div class="diff-comment-head">
123 <div class="diff-comment-avatar">@c.ReviewerName[..1].ToUpper()</div>
124 <span class="diff-comment-author">@c.ReviewerName</span>
125 <span class="diff-comment-time">@FormatTimeAgo(c.CreatedAt)</span>
126 </div>
127 <div class="diff-comment-text">@c.Comment</div>
128 </div>
129 }
130 @if (_inlineCommentFile == cf && _inlineCommentLine == ln)
131 {
132 <div class="diff-comment" style="border-color:var(--accent);border-style:dashed">
133 <div style="display:flex;gap:6px">
134 <input type="text" @bind="_inlineCommentText" @onkeydown="InlineCommentKeyDown" placeholder="Comment on line @ln..."
135 style="flex:1;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px;font-family:var(--mono)" />
136 <button class="btn btn-accent" style="font-size:11px;padding:4px 10px" @onclick="SubmitInlineComment">Send</button>
137 <button class="btn" style="font-size:11px;padding:4px 10px" @onclick="() => _inlineCommentFile = null">&#x2715;</button>
138 </div>
139 </div>
140 }
141 }
142 }
143 else if (op.Type == OpType.Insert)
144 {
145 <div class="diff-line add"><span class="ln">+</span>new file (@op.EndByte bytes)</div>
146 }
147 else if (op.Type == OpType.Delete)
148 {
149 <div class="diff-line del"><span class="ln">-</span>file deleted</div>
150 }
151 else if (op.Type == OpType.Modify && _blobContents.TryGetValue(op.File, out var mc))
152 {
153 <div class="diff-line del"><span class="ln">-</span>previous content</div>
154 var mlines = mc.Split('\n');
155 @for (int i = 0; i < mlines.Length; i++)
156 {
157 var lineNum = i + 1;
158 var cf2 = currentFile;
159 var ln2 = lineNum;
160 <div class="diff-line add">
161 @if (_isReadonly) { <span class="ln">@ln2</span> } else { <span class="ln ln-clickable" @onclick="() => OpenLineComment(cf2, ln2)" title="Comment on line @ln2">@ln2</span> }@mlines[i]
162 </div>
163 @foreach (var c in _reviews.Where(r => r.FilePath == cf2 && r.LineNumber == ln2 && !string.IsNullOrEmpty(r.Comment)))
164 {
165 <div class="diff-comment">
166 <div class="diff-comment-head">
167 <div class="diff-comment-avatar">@c.ReviewerName[..1].ToUpper()</div>
168 <span class="diff-comment-author">@c.ReviewerName</span>
169 <span class="diff-comment-time">@FormatTimeAgo(c.CreatedAt)</span>
170 </div>
171 <div class="diff-comment-text">@c.Comment</div>
172 </div>
173 }
174 @if (_inlineCommentFile == cf2 && _inlineCommentLine == ln2)
175 {
176 <div class="diff-comment" style="border-color:var(--accent);border-style:dashed">
177 <div style="display:flex;gap:6px">
178 <input type="text" @bind="_inlineCommentText" @onkeydown="InlineCommentKeyDown" placeholder="Comment on line @ln2..."
179 style="flex:1;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px;font-family:var(--mono)" />
180 <button class="btn btn-accent" style="font-size:11px;padding:4px 10px" @onclick="SubmitInlineComment">Send</button>
181 <button class="btn" style="font-size:11px;padding:4px 10px" @onclick="() => _inlineCommentFile = null">&#x2715;</button>
182 </div>
183 </div>
184 }
185 }
186 }
187 else if (op.Type == OpType.Modify)
188 {
189 <div class="diff-line del"><span class="ln">-</span>modified (@op.EndByte bytes)</div>
190 }
191 else if (op.Type is OpType.Rename or OpType.Move)
192 {
193 <div class="diff-line ctx"><span class="ln">&gt;</span>@op.File &rarr; @op.NewPath</div>
194 }
195 </div>
196
197 @* File-level comments (no line number) *@
198 @foreach (var review in _reviews.Where(r => r.FilePath == currentFile && r.LineNumber is null && !string.IsNullOrEmpty(r.Comment)))
199 {
200 <div class="diff-comment">
201 <div class="diff-comment-head">
202 <div class="diff-comment-avatar">@review.ReviewerName[..1].ToUpper()</div>
203 <span class="diff-comment-author">@review.ReviewerName</span>
204 <span class="diff-comment-time">@FormatTimeAgo(review.CreatedAt)</span>
205 </div>
206 <div class="diff-comment-text">@review.Comment</div>
207 </div>
208 }
209 </div>
210 }
211 }
212
213 @* ===== FILES TAB ===== *@
214 @if (_activeTab == "files")
215 {
216 <table style="width:100%;border-collapse:collapse">
217 <thead>
218 <tr style="font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px">
219 <th style="text-align:left;padding:8px 0">Type</th>
220 <th style="text-align:left;padding:8px 0">File</th>
221 <th style="text-align:right;padding:8px 0">Lines</th>
222 <th style="text-align:right;padding:8px 0">Size</th>
223 </tr>
224 </thead>
225 <tbody>
226 @foreach (var op in _selected.Ops)
227 {
228 <tr style="border-bottom:1px solid var(--border);font-size:13px">
229 <td style="padding:8px 0;width:30px">
230 <span style="color:@OpColor(op.Type);font-family:var(--mono);font-weight:500">@OpPrefix(op.Type)</span>
231 </td>
232 <td style="padding:8px 0;font-family:var(--mono);font-size:12px">
233 @op.File
234 @if (op.NewPath is not null) { <span style="color:var(--text3)"> &rarr; @op.NewPath</span> }
235 </td>
236 <td style="padding:8px 0;text-align:right;font-family:var(--mono);font-size:11px">
237 @if (op.LinesAdded > 0) { <span style="color:var(--accent)">+@op.LinesAdded</span> }
238 @if (op.LinesDeleted > 0) { <span style="color:var(--danger)"> -@op.LinesDeleted</span> }
239 </td>
240 <td style="padding:8px 0;text-align:right;font-size:11px;color:var(--text3)">
241 @FormatBytes(op.EndByte - op.StartByte)
242 </td>
243 </tr>
244 }
245 </tbody>
246 </table>
247 }
248
249 @* ===== REVIEW TAB ===== *@
250 @if (_activeTab == "review")
251 {
252 <div style="font-size:12px;color:var(--text3);margin-bottom:16px">All comments across this patch. Click line numbers in the Diff tab to add inline comments.</div>
253
254 @if (_reviews.Count > 0)
255 {
256 @foreach (var review in _reviews.Where(r => !string.IsNullOrEmpty(r.Comment)))
257 {
258 <div style="display:flex;gap:10px;padding:12px 0;border-bottom:1px solid var(--border)">
259 <div class="diff-comment-avatar">@review.ReviewerName[..1].ToUpper()</div>
260 <div style="flex:1">
261 <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
262 <span style="font-size:13px;font-weight:500">@review.ReviewerName</span>
263 @if (review.FilePath is not null)
264 {
265 <span style="font-size:10px;font-family:var(--mono);color:var(--accent)">@review.FilePath@(review.LineNumber.HasValue ? $":@review.LineNumber" : "")</span>
266 }
267 else
268 {
269 <span style="font-size:10px;font-family:var(--mono);color:var(--text3)">general</span>
270 }
271 <span style="font-size:10px;color:var(--text3);margin-left:auto">@FormatTimeAgo(review.CreatedAt)</span>
272 </div>
273 <div style="font-size:12px;color:var(--text2)">@review.Comment</div>
274 </div>
275 </div>
276 }
277 }
278 else
279 {
280 <div style="color:var(--text3);font-size:13px;padding:20px 0">No comments yet.</div>
281 }
282 }
283
284 @* ===== OVERLAPS TAB ===== *@
285 @if (_activeTab == "overlaps")
286 {
287 var patchOverlaps = GetOverlapsFor(_selected.Id);
288 if (patchOverlaps.Count > 0)
289 {
290 var proposed = _patches?.Where(p => p.Maturity == Maturity.Proposed && p.Id != _selected.Id).ToList() ?? [];
291 foreach (var other in proposed)
292 {
293 var report = OverlapDetector.Detect(_selected, other);
294 @if (report.HasConflicts)
295 {
296 <div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:12px">
297 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
298 <div>
299 <span style="font-size:13px;font-weight:500">@other.Description</span>
300 <span style="font-size:11px;color:var(--text3);margin-left:8px">by @other.Author</span>
301 </div>
302 <span class="patch-status status-conflict">conflict</span>
303 </div>
304 @foreach (var conflict in report.Conflicts)
305 {
306 <div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-top:1px solid var(--border);font-size:12px">
307 <span style="color:var(--danger);font-family:var(--mono)">!</span>
308 <span style="font-family:var(--mono);color:var(--text2)">@conflict.FileA</span>
309 <span style="color:var(--text3);font-size:11px">@conflict.Reason</span>
310 </div>
311 }
312 </div>
313 }
314 }
315 }
316 else
317 {
318 <div style="color:var(--text3);font-size:13px;padding:20px 0">No overlaps detected.</div>
319 }
320 }
321 </div>
322 }
323 else
324 {
325 <div style="padding:40px;color:var(--text3);font-size:14px">Select a patch to view details</div>
326 }
327 </div>
328</div>
329
330<ConfirmModal @ref="_confirmModal" />
331
332@if (_message is not null)
333{
334 <div style="position:fixed;bottom:20px;right:20px;padding:10px 16px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--text2);z-index:100">@_message</div>
335}
336
337@code {
338 [Parameter] public string ProjectId { get; set; } = "";
339
340 private readonly string[] _filters = ["All", "Mine", "Team", "Proposed", "Draft"];
341 private string _activeFilter = "All";
342 private string _activeTab = "diff";
343 private List<Patch>? _patches;
344 private List<Patch>? _filteredPatches;
345 private string? _selectedId;
346 private Patch? _selected;
347 private Dictionary<string, string> _blobContents = new();
348 private List<Review> _reviews = [];
349 private Dictionary<string, List<string>> _overlapMap = new();
350 private bool _isAuthenticated, _isMaintainer;
351 private string? _userName;
352 private string? _userId;
353 private string? _message;
354 private bool _isReadonly => !_isAuthenticated || _selected?.Maturity == Maturity.Accepted;
355
356 private ConfirmModal _confirmModal = default!;
357
358 // Inline comment state
359 private string? _inlineCommentFile;
360 private int? _inlineCommentLine;
361 private string _inlineCommentText = "";
362
363
364 [Inject] CatenaEvents Events { get; set; } = default!;
365
366 protected override async Task OnInitializedAsync()
367 {
368 Events.PatchChanged += OnChanged;
369 Events.ReviewChanged += OnChanged;
370
371 var authState = await AuthState.GetAuthenticationStateAsync();
372 _isAuthenticated = authState.User.Identity?.IsAuthenticated == true;
373 _isMaintainer = authState.User.IsInRole("Maintainer") || authState.User.IsInRole("Admin");
374 _userName = authState.User.FindFirstValue(ClaimTypes.Name);
375 _userId = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
376
377 await Reload();
378 }
379
380 private async Task SelectPatch(string id)
381 {
382 _selectedId = id;
383 _selected = _patches?.FirstOrDefault(p => p.Id == id);
384 _blobContents.Clear();
385 _reviews = [];
386 _activeTab = "diff";
387
388 if (_selected is not null)
389 {
390 _reviews = await ReviewStore.ListForPatchAsync(ProjectId, _selected.Id);
391
392 for (int i = 0; i < _selected.Ops.Count; i++)
393 {
394 var op = _selected.Ops[i];
395 if (op.Type is OpType.Insert or OpType.Modify)
396 {
397 var blob = await PatchStore.GetBlobAsync(ProjectId, _selected.Id, i);
398 if (blob is not null)
399 {
400 try { _blobContents[op.File] = System.Text.Encoding.UTF8.GetString(blob); }
401 catch { _blobContents[op.File] = $"(binary, {blob.Length} bytes)"; }
402 }
403 }
404 }
405 }
406 }
407
408 private void SetFilter(string filter)
409 {
410 _activeFilter = filter;
411 var source = filter switch
412 {
413 "Proposed" => _patches?.Where(p => p.Maturity == Maturity.Proposed),
414 "Draft" => _patches?.Where(p => p.Maturity is Maturity.DraftLocal or Maturity.DraftSynced or Maturity.DraftShared),
415 "Mine" => _patches?.Where(p => p.Author == _userName),
416 "Team" => _patches?.Where(p => p.Author != _userName),
417 _ => _patches?.AsEnumerable()
418 };
419 // Sort: proposed first, then drafts, then accepted. Newest first within each group.
420 _filteredPatches = source?
421 .OrderBy(p => p.Maturity switch { Maturity.Proposed => 0, Maturity.DraftShared => 1, Maturity.DraftSynced => 2, Maturity.DraftLocal => 3, _ => 4 })
422 .ThenByDescending(p => p.Maturity == Maturity.Accepted ? p.AcceptedAt ?? p.Timestamp : p.Timestamp)
423 .ToList();
424 }
425
426 private List<string> GetOverlapsFor(string patchId) =>
427 _overlapMap.TryGetValue(patchId, out var list) ? list : [];
428
429 private void OpenLineComment(string file, int line)
430 {
431 if (!_isAuthenticated) return;
432 _inlineCommentFile = file;
433 _inlineCommentLine = line;
434 _inlineCommentText = "";
435 }
436
437 private async Task SubmitInlineComment()
438 {
439 if (_selected is null || _inlineCommentFile is null || string.IsNullOrWhiteSpace(_inlineCommentText)) return;
440 await ReviewStore.CreateAsync(ProjectId, _selected.Id, _userId ?? "anon", _userName ?? "anonymous",
441 _inlineCommentFile, _inlineCommentLine, _inlineCommentText);
442 _inlineCommentText = "";
443 _inlineCommentFile = null;
444 _inlineCommentLine = null;
445 _reviews = await ReviewStore.ListForPatchAsync(ProjectId, _selected.Id);
446 }
447
448 private async Task InlineCommentKeyDown(KeyboardEventArgs e)
449 {
450 if (e.Key == "Enter") await SubmitInlineComment();
451 }
452
453 private async Task AcceptPatch()
454 {
455 if (_selected is null) return;
456 if (!await _confirmModal.ShowAsync($"accept {_selected.Id[..8]}", $"Accept \"{_selected.Description}\" into trunk? This updates the trunk state.", "var(--accent)")) return;
457 var (updated, error) = await PatchStore.UpdateMaturityAsync(ProjectId, _selected.Id, Maturity.Accepted);
458 if (error is not null) { _message = error; return; }
459 await TrunkState.ApplyPatchAsync(ProjectId, updated!, PatchStore);
460 Events.NotifyTrunkChanged(ProjectId);
461 Events.NotifyPatchChanged(ProjectId);
462 _message = "Patch accepted.";
463 await Reload();
464 }
465
466 private async Task ProposePatch()
467 {
468 if (_selected is null) return;
469 if (!await _confirmModal.ShowAsync($"propose {_selected.Id[..8]}", $"Propose \"{_selected.Description}\" for review?", "#f59e0b")) return;
470 var (_, error) = await PatchStore.UpdateMaturityAsync(ProjectId, _selected.Id, Maturity.Proposed);
471 if (error is not null) { _message = error; return; }
472 Events.NotifyPatchChanged(ProjectId);
473 _message = "Patch proposed.";
474 await Reload();
475 }
476
477 private async Task WithdrawPatch()
478 {
479 if (_selected is null) return;
480 if (!await _confirmModal.ShowAsync($"withdraw {_selected.Id[..8]}", $"Withdraw \"{_selected.Description}\" back to draft?", "#3b82f6")) return;
481 var (_, error) = await PatchStore.UpdateMaturityAsync(ProjectId, _selected.Id, Maturity.DraftSynced);
482 if (error is not null) { _message = error; return; }
483 Events.NotifyPatchChanged(ProjectId);
484 _message = "Patch withdrawn to draft.";
485 await Reload();
486 }
487
488 private void RejectPatch()
489 {
490 _message = "Reject not yet implemented — revert the patch instead.";
491 }
492
493 private void OnChanged(string pid)
494 {
495 if (pid != ProjectId) return;
496 _ = InvokeAsync(async () => { await Reload(); StateHasChanged(); });
497 }
498
499 private async Task Reload()
500 {
501 _patches = await PatchStore.ListAsync(ProjectId);
502
503 // Rebuild overlap map
504 _overlapMap.Clear();
505 var proposed = _patches.Where(p => p.Maturity == Maturity.Proposed).ToList();
506 foreach (var p in proposed) _overlapMap[p.Id] = [];
507 for (int i = 0; i < proposed.Count; i++)
508 for (int j = i + 1; j < proposed.Count; j++)
509 {
510 var report = OverlapDetector.Detect(proposed[i], proposed[j]);
511 if (report.HasConflicts)
512 {
513 _overlapMap.GetValueOrDefault(proposed[i].Id)?.Add(proposed[j].Description);
514 _overlapMap.GetValueOrDefault(proposed[j].Id)?.Add(proposed[i].Description);
515 }
516 }
517
518 SetFilter(_activeFilter);
519 if (_selectedId is not null)
520 await SelectPatch(_selectedId);
521 else if (_filteredPatches?.Count > 0)
522 await SelectPatch(_filteredPatches[0].Id);
523 }
524
525 public void Dispose()
526 {
527 Events.PatchChanged -= OnChanged;
528 Events.ReviewChanged -= OnChanged;
529 }
530
531 static string StatusClass(Maturity m) => m switch
532 {
533 Maturity.Accepted => "status-accepted",
534 Maturity.Proposed => "status-proposed",
535 _ => "status-draft"
536 };
537
538 static string StatusText(Maturity m) => m switch
539 {
540 Maturity.Accepted => "accepted",
541 Maturity.Proposed => "proposed",
542 Maturity.DraftShared => "shared",
543 Maturity.DraftSynced => "synced",
544 _ => "draft"
545 };
546
547 static string OpPrefix(OpType t) => t switch
548 {
549 OpType.Insert => "+",
550 OpType.Modify => "~",
551 OpType.Delete => "-",
552 OpType.Rename or OpType.Move => ">",
553 _ => "?"
554 };
555
556 static string OpColor(OpType t) => t switch
557 {
558 OpType.Insert => "var(--accent)",
559 OpType.Modify => "var(--proposed)",
560 OpType.Delete => "var(--danger)",
561 _ => "var(--draft)"
562 };
563
564 static string PatchTime(Patch p) => p.Maturity switch
565 {
566 Maturity.Accepted when p.AcceptedAt.HasValue => $"accepted {FormatTimeAgo(p.AcceptedAt.Value)}",
567 Maturity.Proposed when p.ProposedAt.HasValue => $"proposed {FormatTimeAgo(p.ProposedAt.Value)}",
568 _ => FormatTimeAgo(p.Timestamp)
569 };
570
571 static string FormatTimeAgo(DateTime ts)
572 {
573 var d = DateTime.UtcNow - ts;
574 if (d.TotalMinutes < 60) return $"{(int)d.TotalMinutes}m ago";
575 if (d.TotalHours < 24) return $"{(int)d.TotalHours}h ago";
576 if (d.TotalDays < 7) return $"{(int)d.TotalDays}d ago";
577 return ts.ToString("MMM dd");
578 }
579
580 static string FormatBytes(long bytes) => bytes switch
581 {
582 0 => "",
583 < 1024 => $"{bytes} B",
584 < 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
585 _ => $"{bytes / (1024.0 * 1024.0):F1} MB"
586 };
587}
588
+ src/Catena.Server/Components/Pages/ProjectDashboard.razor
1@page "/app/projects/{ProjectId}"
2@layout Catena.Server.Components.Layout.MainLayout
3@implements IDisposable
4@inject ProjectStore ProjectStore
5@inject PatchStore PatchStore
6@inject TrunkState TrunkState
7@using Catena.Shared.Overlap
8
9<div class="topbar">
10 <span class="topbar-title">Dashboard</span>
11 <span class="topbar-sub">@(_project?.Name)</span>
12</div>
13
14<div class="dashboard-content">
15 @if (_project is not null)
16 {
17 <div class="stats-grid">
18 <div class="stat-card">
19 <div class="stat-label">Accepted</div>
20 <div class="stat-value green">@_acceptedCount</div>
21 </div>
22 <div class="stat-card">
23 <div class="stat-label">Proposed</div>
24 <div class="stat-value amber">@_proposedCount</div>
25 </div>
26 <div class="stat-card">
27 <div class="stat-label">Drafts</div>
28 <div class="stat-value blue">@_draftCount</div>
29 </div>
30 <div class="stat-card">
31 <div class="stat-label">Overlaps</div>
32 <div class="stat-value red">@_overlapCount</div>
33 </div>
34 </div>
35
36 <div class="section-title">Recent activity</div>
37 <div class="activity-list">
38 @foreach (var item in _activity)
39 {
40 <div class="activity-item">
41 <div class="activity-dot" style="background:@item.Color"></div>
42 <div class="activity-text">@((MarkupString)item.Text)</div>
43 <div class="activity-time">@item.TimeAgo</div>
44 </div>
45 }
46 </div>
47 }
48</div>
49
50@code {
51 [Parameter] public string ProjectId { get; set; } = "";
52
53 private Project? _project;
54 private int _acceptedCount, _proposedCount, _draftCount, _overlapCount;
55 private List<ActivityItem> _activity = [];
56
57 record ActivityItem(string Color, string Text, string TimeAgo);
58
59 [Inject] CatenaEvents Events { get; set; } = default!;
60
61 protected override async Task OnInitializedAsync()
62 {
63 Events.PatchChanged += OnChanged;
64 Events.TrunkChanged += OnChanged;
65 await LoadData();
66 }
67
68 private void OnChanged(string pid)
69 {
70 if (pid != ProjectId) return;
71 _ = InvokeAsync(async () => { await LoadData(); StateHasChanged(); });
72 }
73
74 private async Task LoadData()
75 {
76 _project = await ProjectStore.GetAsync(ProjectId);
77 if (_project is null) return;
78
79 var patches = await PatchStore.ListAsync(ProjectId);
80 _acceptedCount = patches.Count(p => p.Maturity == Maturity.Accepted);
81 _proposedCount = patches.Count(p => p.Maturity == Maturity.Proposed);
82 _draftCount = patches.Count(p => p.Maturity is Maturity.DraftLocal or Maturity.DraftSynced or Maturity.DraftShared);
83
84 _overlapCount = 0;
85 var proposed = patches.Where(p => p.Maturity == Maturity.Proposed).ToList();
86 for (int i = 0; i < proposed.Count; i++)
87 for (int j = i + 1; j < proposed.Count; j++)
88 if (OverlapDetector.Detect(proposed[i], proposed[j]).HasConflicts) _overlapCount++;
89
90 _activity = patches.OrderByDescending(p => p.Timestamp).Take(10).Select(p => new ActivityItem(
91 p.Maturity switch { Maturity.Accepted => "var(--accent)", Maturity.Proposed => "var(--proposed)", _ => "var(--draft)" },
92 p.Maturity switch
93 {
94 Maturity.Accepted => $"<strong>{p.Description}</strong> accepted",
95 Maturity.Proposed => $"<strong>{p.Description}</strong> proposed by {p.Author}",
96 Maturity.DraftShared => $"<strong>{p.Description}</strong> shared by {p.Author}",
97 _ => $"<strong>{p.Description}</strong> drafted by {p.Author}"
98 },
99 FormatTimeAgo(p.Timestamp)
100 )).ToList();
101 }
102
103 public void Dispose()
104 {
105 Events.PatchChanged -= OnChanged;
106 Events.TrunkChanged -= OnChanged;
107 }
108
109 static string FormatTimeAgo(DateTime ts)
110 {
111 var d = DateTime.UtcNow - ts;
112 if (d.TotalMinutes < 60) return $"{(int)d.TotalMinutes}m ago";
113 if (d.TotalHours < 24) return $"{(int)d.TotalHours}h ago";
114 if (d.TotalDays < 7) return $"{(int)d.TotalDays}d ago";
115 return ts.ToString("MMM dd");
116 }
117}
118
+ src/Catena.Server/Components/Pages/Releases.razor
1@page "/app/projects/{ProjectId}/releases"
2@layout Catena.Server.Components.Layout.MainLayout
3@using Microsoft.AspNetCore.Components.Authorization
4@inject ReleaseStore ReleaseStore
5@inject TrunkState TrunkState
6@inject AuthenticationStateProvider AuthState
7
8<div class="topbar">
9 <span class="topbar-title">Releases</span>
10 <span class="topbar-sub">@(_releases?.Count ?? 0) releases</span>
11 @if (_isAuthenticated)
12 {
13 <div class="topbar-actions">
14 <button class="btn btn-accent" @onclick="() => _showCreate = true">New release</button>
15 </div>
16 }
17</div>
18
19<div class="releases-content">
20 @if (_showCreate)
21 {
22 <div style="display:flex;gap:8px;align-items:center;margin-bottom:16px">
23 <input type="text" @bind="_version" placeholder="v1.0.0" style="padding:6px 12px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--mono)" />
24 <button class="btn btn-accent" @onclick="CreateRelease">Create</button>
25 <button class="btn" @onclick="() => _showCreate = false">Cancel</button>
26 </div>
27 @if (_error is not null)
28 {
29 <div class="error">@_error</div>
30 }
31 }
32
33 @if (_releases is not null)
34 {
35 @foreach (var r in _releases)
36 {
37 <div class="release-card">
38 <div class="release-version">@r.Version</div>
39 <div class="release-info">
40 <div class="release-date">@r.CreatedAt.ToString("MMM dd, yyyy")</div>
41 <div class="release-patches">@r.Files.Count files · trunk hash: @r.TrunkHash</div>
42 </div>
43 <div class="release-tag">frozen</div>
44 </div>
45 }
46 }
47</div>
48
49@code {
50 [Parameter] public string ProjectId { get; set; } = "";
51
52 private List<Release>? _releases;
53 private bool _isAuthenticated;
54 private bool _showCreate;
55 private string _version = "";
56 private string? _error;
57
58 protected override async Task OnInitializedAsync()
59 {
60 _releases = await ReleaseStore.ListAsync(ProjectId);
61 var authState = await AuthState.GetAuthenticationStateAsync();
62 _isAuthenticated = authState.User.Identity?.IsAuthenticated == true;
63 }
64
65 private async Task CreateRelease()
66 {
67 if (string.IsNullOrWhiteSpace(_version)) { _error = "Version required."; return; }
68 if (ReleaseStore.Exists(ProjectId, _version)) { _error = $"{_version} already exists."; return; }
69
70 var files = await TrunkState.GetFilesAsync(ProjectId);
71 var hash = TrunkState.ComputeHash(files);
72 await ReleaseStore.CreateAsync(ProjectId, _version, files, hash);
73
74 _showCreate = false; _version = ""; _error = null;
75 _releases = await ReleaseStore.ListAsync(ProjectId);
76 }
77}
78
+ src/Catena.Server/Components/Pages/Settings.razor
1@page "/app/projects/{ProjectId}/settings"
2@layout Catena.Server.Components.Layout.MainLayout
3@using Microsoft.AspNetCore.Components.Authorization
4@using System.Security.Claims
5@inject ProjectStore ProjectStore
6@inject PatchStore PatchStore
7@inject UserStore UserStore
8@inject WebhookStore WebhookStore
9@inject AuthenticationStateProvider AuthState
10
11<div class="topbar">
12 <span class="topbar-title">Settings</span>
13 <span class="topbar-sub">@(_project?.Name)</span>
14</div>
15
16<div class="settings-content">
17 @if (_project is not null)
18 {
19 <div class="setting-section">
20 <div class="setting-section-title">Project</div>
21 <div class="setting-row">
22 <span class="setting-label">Name</span>
23 <span class="setting-value">@_project.Name</span>
24 </div>
25 <div class="setting-row">
26 <span class="setting-label">ID</span>
27 <span class="setting-value">@_project.Id</span>
28 </div>
29 <div class="setting-row">
30 <span class="setting-label">Visibility</span>
31 <span class="setting-value">@(_project.IsPublic ? "public" : "private")</span>
32 </div>
33 <div class="setting-row">
34 <span class="setting-label">Patches</span>
35 <span class="setting-value">@_patchCount patches</span>
36 </div>
37 </div>
38
39 @if (_isAuthenticated)
40 {
41 <div class="setting-section">
42 <div class="setting-section-title" style="display:flex;justify-content:space-between;align-items:center">
43 Team
44 @if (_isAdmin)
45 {
46 <button class="btn btn-accent" style="font-size:11px;padding:4px 10px" @onclick="() => _showCreateUser = !_showCreateUser">+ Add user</button>
47 }
48 </div>
49
50 @if (_showCreateUser)
51 {
52 <div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:14px">
53 <div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
54 <input type="text" @bind="_newUserName" placeholder="Username"
55 style="padding:6px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--font);font-size:12px;flex:1" />
56 <select @bind="_newUserRole"
57 style="padding:6px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--font);font-size:12px">
58 <option value="Developer">Developer</option>
59 <option value="Maintainer">Maintainer</option>
60 <option value="Admin">Admin</option>
61 </select>
62 <button class="btn btn-accent" @onclick="CreateUser">Create</button>
63 </div>
64
65 @if (_createError is not null)
66 {
67 <div style="font-size:11px;color:var(--danger)">@_createError</div>
68 }
69
70 @if (_createdKey is not null)
71 {
72 <div style="background:var(--accent-dim);border:1px solid #166534;border-radius:6px;padding:12px;margin-top:8px">
73 <div style="font-size:11px;color:var(--accent);font-weight:500;margin-bottom:6px">API Key created — copy now, shown only once:</div>
74 <div style="font-family:var(--mono);font-size:12px;color:var(--accent);word-break:break-all;user-select:all;cursor:text;background:var(--bg);padding:8px;border-radius:4px">@_createdKey</div>
75 </div>
76 }
77 </div>
78 }
79
80 @foreach (var user in _users)
81 {
82 <div class="user-row">
83 <div class="user-dot" style="@UserDotStyle(user.Role)">@user.Name[..1].ToUpper()</div>
84 <span>@user.Name</span>
85 @if (_isAdmin)
86 {
87 var uid = user.Id;
88 <select value="@user.Role" @onchange="e => ChangeRole(uid, e.Value?.ToString())"
89 class="user-role @RoleClass(user.Role)"
90 style="border:none;cursor:pointer;appearance:auto;background:transparent">
91 <option value="Developer" selected="@(user.Role == UserRole.Developer)">developer</option>
92 <option value="Maintainer" selected="@(user.Role == UserRole.Maintainer)">maintainer</option>
93 <option value="Admin" selected="@(user.Role == UserRole.Admin)">admin</option>
94 </select>
95 }
96 else
97 {
98 <span class="user-role @RoleClass(user.Role)">@user.Role.ToString().ToLower()</span>
99 }
100 </div>
101 }
102
103 @if (_users.Count == 0)
104 {
105 <div style="font-size:12px;color:var(--text3);padding:8px 0">No users yet.</div>
106 }
107 </div>
108
109 <div class="setting-section">
110 <div class="setting-section-title">API Keys</div>
111 <div class="setting-row">
112 <span class="setting-label">Your key</span>
113 <span class="setting-value">cat_••••••••</span>
114 </div>
115 </div>
116
117 <div class="setting-section">
118 <div class="setting-section-title" style="display:flex;justify-content:space-between;align-items:center">
119 Webhooks
120 @if (_isAdmin)
121 {
122 <button class="btn" style="font-size:11px;padding:4px 10px" @onclick="() => _showAddHook = !_showAddHook">+ Add</button>
123 }
124 </div>
125
126 @if (_showAddHook)
127 {
128 <div style="display:flex;gap:6px;margin-bottom:12px">
129 <select @bind="_newEvent" style="padding:4px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px;font-family:var(--mono)">
130 <option value="patch.proposed">patch.proposed</option>
131 <option value="patch.accepted">patch.accepted</option>
132 <option value="release.created">release.created</option>
133 <option value="conflict.detected">conflict.detected</option>
134 </select>
135 <input type="text" @bind="_newUrl" placeholder="https://..." style="padding:4px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px;flex:1" />
136 <button class="btn btn-accent" @onclick="AddWebhook">Add</button>
137 </div>
138 }
139
140 @if (_webhooks.Count > 0)
141 {
142 @foreach (var hook in _webhooks)
143 {
144 <div class="setting-row">
145 <span class="setting-label" style="font-family:var(--mono);font-size:11px">@hook.Event</span>
146 <span class="setting-value">@hook.Url</span>
147 </div>
148 }
149 }
150 else if (!_showAddHook)
151 {
152 <div style="font-size:12px;color:var(--text3);padding:8px 0">No webhooks configured.</div>
153 }
154 </div>
155 }
156 else
157 {
158 <div style="font-size:13px;color:var(--text3);padding:20px 0">
159 <a href="/login" style="color:var(--accent)">Sign in</a> to manage settings.
160 </div>
161 }
162 }
163</div>
164
165@code {
166 [Parameter] public string ProjectId { get; set; } = "";
167
168 private Project? _project;
169 private int _patchCount;
170 private List<User> _users = [];
171 private List<Webhook> _webhooks = [];
172 private bool _isAuthenticated, _isAdmin;
173
174 // Create user
175 private bool _showCreateUser;
176 private string _newUserName = "";
177 private string _newUserRole = "Developer";
178 private string? _createdKey;
179 private string? _createError;
180
181 // Webhooks
182 private bool _showAddHook;
183 private string _newEvent = "patch.proposed";
184 private string _newUrl = "";
185
186 protected override async Task OnInitializedAsync()
187 {
188 _project = await ProjectStore.GetAsync(ProjectId);
189 if (_project is null) return;
190
191 var patches = await PatchStore.ListAsync(ProjectId);
192 _patchCount = patches.Count;
193
194 var authState = await AuthState.GetAuthenticationStateAsync();
195 _isAuthenticated = authState.User.Identity?.IsAuthenticated == true;
196 _isAdmin = authState.User.IsInRole("Admin");
197
198 if (_isAuthenticated)
199 {
200 _users = await UserStore.ListAsync();
201 _webhooks = await WebhookStore.ListAsync(ProjectId);
202 }
203 }
204
205 private async Task CreateUser()
206 {
207 _createError = null;
208 _createdKey = null;
209
210 if (string.IsNullOrWhiteSpace(_newUserName))
211 {
212 _createError = "Username is required.";
213 return;
214 }
215
216 var role = Enum.Parse<UserRole>(_newUserRole);
217 var (user, key) = await UserStore.CreateAsync(_newUserName, role);
218 _createdKey = key;
219 _newUserName = "";
220 _users = await UserStore.ListAsync();
221 }
222
223 private async Task ChangeRole(string userId, string? newRole)
224 {
225 if (newRole is null) return;
226 var role = Enum.Parse<UserRole>(newRole);
227 await UserStore.UpdateRoleAsync(userId, role);
228 _users = await UserStore.ListAsync();
229 }
230
231 private async Task AddWebhook()
232 {
233 if (string.IsNullOrWhiteSpace(_newUrl)) return;
234 await WebhookStore.CreateAsync(ProjectId, _newEvent, _newUrl);
235 _webhooks = await WebhookStore.ListAsync(ProjectId);
236 _newUrl = "";
237 _showAddHook = false;
238 }
239
240 static string UserDotStyle(UserRole role) => role switch
241 {
242 UserRole.Admin => "background:var(--danger-dim);border-color:var(--danger);color:var(--danger)",
243 UserRole.Maintainer => "background:var(--proposed-dim);border-color:var(--proposed);color:var(--proposed)",
244 _ => "background:var(--accent-dim);border-color:var(--accent);color:var(--accent)"
245 };
246
247 static string RoleClass(UserRole role) => role switch
248 {
249 UserRole.Admin => "role-admin",
250 UserRole.Maintainer => "role-maintainer",
251 _ => "role-dev"
252 };
253}
254
+ src/Catena.Server/Components/Pages/Timeline.razor
1@page "/app/projects/{ProjectId}/timeline"
2@layout Catena.Server.Components.Layout.MainLayout
3@implements IDisposable
4@inject PatchStore PatchStore
5@inject ReleaseStore ReleaseStore
6@inject Microsoft.JSInterop.IJSRuntime JS
7@using Catena.Shared.Overlap
8
9<div class="topbar">
10 <span class="topbar-title">Timeline</span>
11 <span class="topbar-sub">Patch chain</span>
12 <div class="tl-legend">
13 <span><i style="background:var(--accent)"></i>accepted</span>
14 <span><i style="background:#f59e0b"></i>proposed</span>
15 <span><i style="background:#3b82f6"></i>draft</span>
16 <span><i style="background:#a78bfa"></i>release</span>
17 </div>
18</div>
19
20<div class="tl-split">
21 @* ===== TOP ZONE: WIP + next accept + anchor ===== *@
22 <div class="tl-wip-zone" id="tl-wip-zone">
23 <div class="tl-graph" id="tl-wip-graph" style="min-width:@(_wipWidth)px;min-height:@(_wipHeight)px">
24 <svg class="tl-edges" id="tl-wip-edges"></svg>
25
26 @foreach (var node in _wipNodes)
27 {
28 <div class="tl-node tl-node-@node.Type" id="node-@node.Id"
29 style="left:@(node.X)px;top:@(node.Y)px;@(node.Dim ? "opacity:.5;" : "")"
30 @onclick="() => SelectNode(node)">
31 @if (node.DotClass is not null)
32 {
33 <div class="tl-nd @node.DotClass"></div>
34 }
35 <div class="tl-ni">
36 <span class="tl-nn" style="color:@node.Color">@node.Label</span>
37 @if (node.Sub is not null) { <span class="tl-ns">@node.Sub</span> }
38 @if (node.Dep is not null) { <span class="tl-ndep">@node.Dep</span> }
39 </div>
40 </div>
41 }
42 </div>
43 </div>
44
45 @* ===== DIVIDER ===== *@
46 <div class="tl-divider"></div>
47
48 @* ===== BOTTOM ZONE: Trunk history + Releases ===== *@
49 <div class="tl-trunk-zone" id="tl-trunk-zone">
50 <div class="tl-graph" id="tl-trunk-graph" style="min-width:400px;min-height:@(_trunkHeight)px">
51 <svg class="tl-edges" id="tl-trunk-edges"></svg>
52
53 @foreach (var node in _trunkNodes)
54 {
55 <div class="tl-node tl-node-@node.Type" id="node-@node.Id"
56 style="left:@(node.X)px;top:@(node.Y)px"
57 @onclick="() => SelectNode(node)">
58 @if (node.DotClass is not null)
59 {
60 <div class="tl-nd @node.DotClass"></div>
61 }
62 <div class="tl-ni">
63 <span class="tl-nn" style="color:@node.Color">@node.Label</span>
64 @if (node.Sub is not null) { <span class="tl-ns">@node.Sub</span> }
65 </div>
66 </div>
67 }
68
69 @if (_hasMoreTrunk)
70 {
71 <div style="position:absolute;left:@(X_TRUNK - 40)px;top:@(_trunkHeight - 30)px">
72 <button class="btn" @onclick="LoadMoreTrunk" style="font-size:11px">Load older patches...</button>
73 </div>
74 }
75 </div>
76 </div>
77</div>
78
79@* ===== DETAIL PANEL ===== *@
80@if (_selectedPatch is not null)
81{
82 <div class="tl-detail-panel">
83 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
84 <span style="font-size:15px;font-weight:500">@_selectedPatch.Description</span>
85 <button class="btn" style="font-size:10px;padding:2px 8px" @onclick="() => _selectedPatch = null">&#x2715;</button>
86 </div>
87 <div style="font-size:11px;color:var(--text3);margin-bottom:12px">
88 @_selectedPatch.Author · @StatusText(_selectedPatch.Maturity) · @_selectedPatch.Ops.Count files · @FormatTimeAgo(_selectedPatch.Timestamp)
89 </div>
90
91 @if (_selectedPatch.Dependencies.Count > 0)
92 {
93 <div style="font-size:11px;color:var(--draft);margin-bottom:8px">
94 Dependencies: @string.Join(", ", _selectedPatch.Dependencies.Select(d => d[..Math.Min(8, d.Length)]))
95 </div>
96 }
97
98 <div style="font-size:11px;font-weight:500;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Files</div>
99 @foreach (var op in _selectedPatch.Ops)
100 {
101 <div style="font-family:var(--mono);font-size:11px;padding:3px 0;color:var(--text2)">
102 <span style="color:@OpColor(op.Type);font-weight:500">@OpPrefix(op.Type)</span> @op.File
103 </div>
104 }
105
106 <div style="margin-top:12px">
107 <a href="/app/projects/@ProjectId/patches" class="btn btn-accent" style="font-size:11px;text-decoration:none">Open in Patches →</a>
108 </div>
109 </div>
110}
111
112@code {
113 [Parameter] public string ProjectId { get; set; } = "";
114
115 [Inject] CatenaEvents Events { get; set; } = default!;
116
117 const int X_REL = 60;
118 const int X_TRUNK = 200;
119 const int X_WIP1 = 420;
120 const int X_WIP_GAP = 240;
121 const int ROW = 62;
122 const int TRUNK_PAGE_SIZE = 20;
123
124 private List<TimelineNode> _wipNodes = [];
125 private List<TimelineNode> _trunkNodes = [];
126 private List<TimelineEdge> _wipEdges = [];
127 private List<TimelineEdge> _trunkEdges = [];
128 private List<(string A, string B)> _overlaps = [];
129 private int _wipWidth = 960, _wipHeight = 400;
130 private int _trunkHeight = 400;
131 private bool _hasMoreTrunk;
132 private int _trunkLoaded;
133 private Patch? _selectedPatch;
134
135 // All accepted for lazy loading
136 private List<Patch> _allAccepted = [];
137
138 record TimelineNode(string Id, string Type, string Label, string Color, int X, int Y,
139 string? Sub = null, string? Dep = null, string? DotClass = null, bool Dim = false, string? PatchId = null);
140 record TimelineEdge(string From, string To, string Color, float Width, string? Dash = null, string? Route = null);
141
142 protected override async Task OnInitializedAsync()
143 {
144 Events.PatchChanged += OnChanged;
145 Events.TrunkChanged += OnChanged;
146 await BuildTimeline();
147 }
148
149 private void OnChanged(string pid)
150 {
151 if (pid != ProjectId) return;
152 _ = InvokeAsync(async () =>
153 {
154 await BuildTimeline();
155 StateHasChanged();
156 await Task.Delay(150);
157 await DrawAllEdges();
158 });
159 }
160
161 private async Task BuildTimeline()
162 {
163 var patches = await PatchStore.ListAsync(ProjectId);
164 var releases = await ReleaseStore.ListAsync(ProjectId);
165
166 _allAccepted = patches.Where(p => p.Maturity == Maturity.Accepted).OrderByDescending(p => p.Timestamp).ToList();
167 var proposed = patches.Where(p => p.Maturity == Maturity.Proposed).OrderByDescending(p => p.Timestamp).ToList();
168 var drafts = patches.Where(p => p.Maturity is Maturity.DraftSynced or Maturity.DraftShared).OrderByDescending(p => p.Timestamp).ToList();
169
170 // Overlaps
171 _overlaps.Clear();
172 for (int i = 0; i < proposed.Count; i++)
173 for (int j = i + 1; j < proposed.Count; j++)
174 if (OverlapDetector.Detect(proposed[i], proposed[j]).HasConflicts)
175 _overlaps.Add((proposed[i].Id, proposed[j].Id));
176
177 var wipPatches = proposed.Concat(drafts).ToList();
178 var depthMap = new Dictionary<string, int>();
179 foreach (var p in wipPatches) depthMap[p.Id] = ComputeDepth(p, wipPatches, depthMap);
180 var wipSorted = wipPatches.OrderByDescending(p => depthMap.GetValueOrDefault(p.Id, 0)).ThenBy(p => p.Timestamp).ToList();
181
182 // ===== BUILD WIP ZONE =====
183 _wipNodes.Clear();
184 _wipEdges.Clear();
185 int row = 0;
186
187 // Deps at top
188 var wipWithDeps = wipSorted.Where(p => p.Dependencies.Count > 0).ToList();
189 foreach (var p in wipWithDeps)
190 {
191 var depNames = string.Join(" + ", p.Dependencies.Select(d =>
192 patches.FirstOrDefault(pp => pp.Id == d)?.Description ?? d[..Math.Min(8, d.Length)]));
193 _wipNodes.Add(new TimelineNode(p.Id, NodeType(p), p.Description, NodeColor(p), X_WIP1, ROW * row + 20,
194 Sub: $"{p.Author} · {MaturityText(p.Maturity)}", Dep: $"needs: {depNames}",
195 DotClass: DotClass(p), PatchId: p.Id));
196 row++;
197 }
198
199 // "next accept"
200 _wipNodes.Add(new TimelineNode("next", "next", "next accept", "var(--text3)", X_TRUNK, ROW * row + 20));
201 int nextRow = row;
202 row++;
203
204 // Proposed + drafts without deps side by side
205 var wipNoDeps = wipSorted.Where(p => p.Dependencies.Count == 0).ToList();
206 int wipCol = 0, wipRow = row;
207 foreach (var p in wipNoDeps)
208 {
209 var x = X_WIP1 + wipCol * X_WIP_GAP;
210 var dim = p.Maturity == Maturity.DraftSynced;
211 _wipNodes.Add(new TimelineNode(p.Id, NodeType(p), p.Description, NodeColor(p), x, ROW * wipRow + 20,
212 Sub: $"{p.Author} · {MaturityText(p.Maturity)}", DotClass: DotClass(p), Dim: dim, PatchId: p.Id));
213 wipCol++;
214 if (wipCol >= 4) { wipCol = 0; wipRow++; }
215 }
216 if (wipNoDeps.Count > 0) row = wipRow + 1;
217
218 // Anchor: last accepted patch at bottom of WIP zone
219 if (_allAccepted.Count > 0)
220 {
221 var anchor = _allAccepted[0];
222 _wipNodes.Add(new TimelineNode(anchor.Id, "accepted", anchor.Description, "var(--accent)", X_TRUNK, ROW * row + 20,
223 Sub: $"{anchor.Author} · {FormatTimeAgo(anchor.Timestamp)}", DotClass: "tl-nd-accepted", PatchId: anchor.Id));
224 row++;
225 }
226
227 _wipWidth = Math.Max(960, _wipNodes.Count > 0 ? _wipNodes.Max(n => n.X) + 280 : 960);
228 _wipHeight = ROW * row + 40;
229
230 // WIP edges
231 if (_allAccepted.Count > 0)
232 {
233 var anchorId = _allAccepted[0].Id;
234 _wipEdges.Add(new TimelineEdge("next", anchorId, "#22c55e", 3));
235
236 foreach (var p in proposed.Where(pp => pp.Dependencies.Count == 0))
237 _wipEdges.Add(new TimelineEdge(p.Id, "next", "#92400e", 1.5f, "4,3", "vu"));
238
239 foreach (var p in wipNoDeps)
240 _wipEdges.Add(new TimelineEdge(anchorId, p.Id, "#92400e", 1.5f, "3,3", "hv"));
241 }
242
243 foreach (var p in wipPatches.Where(pp => pp.Dependencies.Count > 0))
244 foreach (var depId in p.Dependencies)
245 if (wipPatches.Any(w => w.Id == depId))
246 _wipEdges.Add(new TimelineEdge(depId, p.Id, "#1e3a5f", 1.5f, "5,4"));
247
248 // ===== BUILD TRUNK ZONE (lazy) =====
249 _trunkLoaded = Math.Min(TRUNK_PAGE_SIZE, _allAccepted.Count);
250 _hasMoreTrunk = _allAccepted.Count > _trunkLoaded;
251 BuildTrunkNodes(releases);
252 }
253
254 private void BuildTrunkNodes(List<Release>? releases = null)
255 {
256 _trunkNodes.Clear();
257 _trunkEdges.Clear();
258
259 // Skip first (anchor is in WIP zone)
260 var trunkPatches = _allAccepted.Skip(1).Take(_trunkLoaded).ToList();
261 int row = 0;
262
263 foreach (var p in trunkPatches)
264 {
265 _trunkNodes.Add(new TimelineNode(p.Id, "accepted", p.Description, "var(--accent)", X_TRUNK, ROW * row + 20,
266 Sub: $"{p.Author} · {FormatTimeAgo(p.Timestamp)}", DotClass: "tl-nd-accepted", PatchId: p.Id));
267 row++;
268 }
269
270 // Trunk chain edges
271 for (int i = 0; i < trunkPatches.Count - 1; i++)
272 _trunkEdges.Add(new TimelineEdge(trunkPatches[i].Id, trunkPatches[i + 1].Id, "#22c55e", 3));
273
274 // Releases — find the trunk patch that was newest when the release was created
275 if (releases is not null)
276 {
277 foreach (var rel in releases)
278 {
279 // Find the accepted patch closest before the release creation time
280 var basePatch = trunkPatches
281 .Where(p => p.Timestamp <= rel.CreatedAt)
282 .OrderByDescending(p => p.Timestamp)
283 .FirstOrDefault();
284
285 var baseNode = basePatch is not null
286 ? _trunkNodes.FirstOrDefault(n => n.PatchId == basePatch.Id)
287 : _trunkNodes.FirstOrDefault();
288
289 // Offset release pill down by ~7px to vertically center with trunk node
290 var relY = (baseNode?.Y ?? 20) + 7;
291 var relNodeId = $"rel-{rel.Version}";
292 _trunkNodes.Add(new TimelineNode(relNodeId, "release", rel.Version, "var(--release)", X_REL, relY));
293
294 if (baseNode is not null)
295 _trunkEdges.Add(new TimelineEdge(baseNode.Id, relNodeId, "#a78bfa", 1.5f, "6,4", "h"));
296 }
297 }
298
299 _trunkHeight = Math.Max(300, ROW * (row + 1) + 40);
300 }
301
302 private async Task LoadMoreTrunk()
303 {
304 _trunkLoaded = Math.Min(_trunkLoaded + TRUNK_PAGE_SIZE, _allAccepted.Count);
305 _hasMoreTrunk = _allAccepted.Count > _trunkLoaded + 1; // +1 for anchor
306 var releases = await ReleaseStore.ListAsync(ProjectId);
307 BuildTrunkNodes(releases);
308 StateHasChanged();
309 await Task.Delay(100);
310 await DrawAllEdges();
311 }
312
313 protected override async Task OnAfterRenderAsync(bool firstRender)
314 {
315 if (firstRender)
316 {
317 await Task.Delay(150);
318 await DrawAllEdges();
319 }
320 }
321
322 private async Task DrawAllEdges()
323 {
324 try
325 {
326 var wipJson = System.Text.Json.JsonSerializer.Serialize(_wipEdges.Select(e => new
327 { from = e.From, to = e.To, color = e.Color, width = e.Width, dash = e.Dash, route = e.Route }));
328 await JS.InvokeVoidAsync("catenaTimeline.drawEdgesIn", "tl-wip-graph", "tl-wip-edges", wipJson);
329
330 var trunkJson = System.Text.Json.JsonSerializer.Serialize(_trunkEdges.Select(e => new
331 { from = e.From, to = e.To, color = e.Color, width = e.Width, dash = e.Dash, route = e.Route }));
332 await JS.InvokeVoidAsync("catenaTimeline.drawEdgesIn", "tl-trunk-graph", "tl-trunk-edges", trunkJson);
333
334 foreach (var (a, b) in _overlaps)
335 await JS.InvokeVoidAsync("catenaTimeline.drawOverlapIn", "tl-wip-graph", "tl-wip-edges", a, b);
336 }
337 catch { }
338 }
339
340 private async Task SelectNode(TimelineNode node)
341 {
342 if (node.PatchId is not null)
343 _selectedPatch = await PatchStore.GetAsync(ProjectId, node.PatchId);
344 }
345
346 static int ComputeDepth(Patch p, List<Patch> all, Dictionary<string, int> cache)
347 {
348 if (cache.TryGetValue(p.Id, out var cached)) return cached;
349 if (p.Dependencies.Count == 0) return 0;
350 var maxDep = p.Dependencies.Select(depId => all.FirstOrDefault(pp => pp.Id == depId))
351 .Where(dep => dep is not null)
352 .Max(dep => ComputeDepth(dep!, all, cache) + 1);
353 cache[p.Id] = maxDep;
354 return maxDep;
355 }
356
357 static string NodeType(Patch p) => p.Maturity == Maturity.Proposed ? "proposed" : "draft";
358 static string NodeColor(Patch p) => p.Maturity == Maturity.Proposed ? "#f59e0b" : "#3b82f6";
359 static string DotClass(Patch p) => p.Maturity switch { Maturity.Proposed => "tl-nd-proposed", Maturity.DraftShared => "tl-nd-shared", _ => "tl-nd-synced" };
360 static string MaturityText(Maturity m) => m switch { Maturity.Proposed => "proposed", Maturity.DraftShared => "shared", Maturity.DraftSynced => "synced", _ => "draft" };
361 static string StatusText(Maturity m) => m switch { Maturity.Accepted => "accepted", Maturity.Proposed => "proposed", _ => MaturityText(m) };
362 static string OpPrefix(OpType t) => t switch { OpType.Insert => "+", OpType.Modify => "~", OpType.Delete => "-", _ => ">" };
363 static string OpColor(OpType t) => t switch { OpType.Insert => "var(--accent)", OpType.Modify => "#f59e0b", OpType.Delete => "var(--danger)", _ => "var(--draft)" };
364 static string FormatTimeAgo(DateTime ts) { var d = DateTime.UtcNow - ts; if (d.TotalMinutes < 60) return $"{(int)d.TotalMinutes}m ago"; if (d.TotalHours < 24) return $"{(int)d.TotalHours}h ago"; if (d.TotalDays < 7) return $"{(int)d.TotalDays}d ago"; return ts.ToString("MMM dd"); }
365
366 public void Dispose()
367 {
368 Events.PatchChanged -= OnChanged;
369 Events.TrunkChanged -= OnChanged;
370 }
371}
372
+ src/Catena.Server/Components/Pages/Users.razor
1@page "/app/admin/users"
2@layout Catena.Server.Components.Layout.MainLayout
3@using Microsoft.AspNetCore.Components.Authorization
4@inject UserStore UserStore
5@inject AuthenticationStateProvider AuthState
6
7<h1>User Management</h1>
8
9@if (!_isAdmin)
10{
11 <p>Admin access required.</p>
12}
13else
14{
15 @if (_showCreate)
16 {
17 <div class="create-form">
18 <input type="text" @bind="_newName" placeholder="Username" />
19 <select @bind="_newRole">
20 <option value="Developer">Developer</option>
21 <option value="Maintainer">Maintainer</option>
22 <option value="Admin">Admin</option>
23 </select>
24 <button class="btn accept" @onclick="CreateUser">Create</button>
25 <button class="btn" @onclick="() => _showCreate = false">Cancel</button>
26 </div>
27
28 @if (_createdKey is not null)
29 {
30 <div class="key-display">
31 <strong>API Key (save now — shown only once):</strong>
32 <code>@_createdKey</code>
33 </div>
34 }
35 }
36 else
37 {
38 <button class="btn propose" @onclick="() => _showCreate = true">Create User</button>
39 }
40
41 @if (_users is not null)
42 {
43 <table class="data-table">
44 <thead>
45 <tr><th>Name</th><th>Role</th><th>Created</th><th>ID</th></tr>
46 </thead>
47 <tbody>
48 @foreach (var u in _users)
49 {
50 <tr>
51 <td>@u.Name</td>
52 <td><span class="badge @u.Role.ToString().ToLower()">@u.Role</span></td>
53 <td>@u.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
54 <td><code>@u.Id</code></td>
55 </tr>
56 }
57 </tbody>
58 </table>
59 }
60}
61
62@code {
63 private List<User>? _users;
64 private bool _isAdmin;
65 private bool _showCreate;
66 private string _newName = "";
67 private string _newRole = "Developer";
68 private string? _createdKey;
69
70 protected override async Task OnInitializedAsync()
71 {
72 var authState = await AuthState.GetAuthenticationStateAsync();
73 var role = authState.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
74 _isAdmin = role == "Admin";
75
76 if (_isAdmin)
77 _users = await UserStore.ListAsync();
78 }
79
80 private async Task CreateUser()
81 {
82 if (string.IsNullOrWhiteSpace(_newName)) return;
83 var role = Enum.Parse<UserRole>(_newRole);
84 var (_, key) = await UserStore.CreateAsync(_newName, role);
85 _createdKey = key;
86 _newName = "";
87 _users = await UserStore.ListAsync();
88 }
89}
90
+ src/Catena.Server/Components/Routes.razor
1@using Catena.Server.Components.Layout
2
3<Router AppAssembly="typeof(Routes).Assembly">
4 <Found Context="routeData">
5 <RouteView RouteData="routeData" DefaultLayout="typeof(EmptyLayout)" />
6 <FocusOnNavigate RouteData="routeData" Selector="h1" />
7 </Found>
8 <NotFound>
9 <LayoutView Layout="typeof(MainLayout)">
10 <p>Page not found.</p>
11 </LayoutView>
12 </NotFound>
13</Router>
14
+ src/Catena.Server/Components/Shared/ConfirmModal.razor
1@if (_visible)
2{
3 <div class="confirm-overlay" @onclick="Cancel">
4 <div class="confirm-term" @onclick:stopPropagation="true">
5 <div class="confirm-bar">
6 <div class="confirm-dot" style="background:var(--danger)"></div>
7 <div class="confirm-dot" style="background:#f59e0b"></div>
8 <div class="confirm-dot" style="background:var(--accent)"></div>
9 <span style="flex:1"></span>
10 <span style="font-size:10px;color:var(--text3);font-family:var(--mono)">confirm</span>
11 </div>
12 <div class="confirm-body">
13 <div class="confirm-line"><span class="confirm-prompt">$</span> <span style="color:var(--text2)">catena @Command</span></div>
14 <div class="confirm-line" style="color:@ActionColor;margin:6px 0">@Message</div>
15 <div style="display:flex;gap:8px;margin-top:12px">
16 <button class="btn btn-accent" style="font-family:var(--mono);font-size:11px" @onclick="Confirm">confirm ↵</button>
17 <button class="btn" style="font-family:var(--mono);font-size:11px" @onclick="Cancel">cancel</button>
18 </div>
19 </div>
20 </div>
21 </div>
22}
23
24<style>
25 .confirm-overlay {
26 position: fixed; inset: 0; z-index: 9999;
27 background: rgba(9,9,11,.85); backdrop-filter: blur(4px);
28 display: flex; align-items: center; justify-content: center;
29 }
30 .confirm-term {
31 background: #0a0a0c; border: 1px solid var(--border); border-radius: 10px;
32 width: 380px; overflow: hidden;
33 }
34 .confirm-bar {
35 display: flex; align-items: center; gap: 6px;
36 padding: 10px 14px; border-bottom: 1px solid var(--border);
37 }
38 .confirm-dot { width: 10px; height: 10px; border-radius: 50%; }
39 .confirm-body {
40 padding: 16px; font-family: var(--mono); font-size: 12px; line-height: 1.8;
41 }
42 .confirm-line { color: var(--text3); }
43 .confirm-prompt { color: var(--accent); margin-right: 6px; }
44</style>
45
46@code {
47 private bool _visible;
48 private TaskCompletionSource<bool>? _tcs;
49
50 [Parameter] public string Command { get; set; } = "";
51 [Parameter] public string Message { get; set; } = "";
52 [Parameter] public string ActionColor { get; set; } = "var(--accent)";
53
54 public Task<bool> ShowAsync(string command, string message, string color = "var(--accent)")
55 {
56 Command = command;
57 Message = message;
58 ActionColor = color;
59 _visible = true;
60 _tcs = new TaskCompletionSource<bool>();
61 StateHasChanged();
62 return _tcs.Task;
63 }
64
65 private void Confirm()
66 {
67 _visible = false;
68 _tcs?.SetResult(true);
69 }
70
71 private void Cancel()
72 {
73 _visible = false;
74 _tcs?.SetResult(false);
75 }
76}
77
+ src/Catena.Server/Components/Shared/LiveComponentBase.cs
1using Catena.Storage;
2using Microsoft.AspNetCore.Components;
3
4namespace Catena.Server.Components.Shared;
5
6/// <summary>
7/// Base class for Blazor components that auto-refresh when server data changes.
8/// Subscribe to specific events by overriding OnProjectDataChanged.
9/// </summary>
10public abstract class LiveComponentBase : ComponentBase, IDisposable
11{
12 [Inject] protected CatenaEvents Events { get; set; } = default!;
13
14 [Parameter] public string ProjectId { get; set; } = "";
15
16 protected override void OnInitialized()
17 {
18 Events.PatchChanged += OnDataChanged;
19 Events.TrunkChanged += OnDataChanged;
20 Events.ReleaseChanged += OnDataChanged;
21 Events.ReviewChanged += OnDataChanged;
22 }
23
24 private void OnDataChanged(string projectId)
25 {
26 if (projectId != ProjectId) return;
27 _ = InvokeAsync(async () =>
28 {
29 await OnProjectDataChanged();
30 StateHasChanged();
31 });
32 }
33
34 /// <summary>
35 /// Override to reload data when the project's data changes.
36 /// Called automatically — just re-fetch your data here.
37 /// </summary>
38 protected virtual Task OnProjectDataChanged() => Task.CompletedTask;
39
40 public virtual void Dispose()
41 {
42 Events.PatchChanged -= OnDataChanged;
43 Events.TrunkChanged -= OnDataChanged;
44 Events.ReleaseChanged -= OnDataChanged;
45 Events.ReviewChanged -= OnDataChanged;
46 }
47}
48
+ src/Catena.Server/Components/Shared/MaturityBadge.razor
1@code {
2 [Parameter] public required Maturity Value { get; set; }
3
4 private string CssClass => Value switch
5 {
6 Maturity.DraftLocal => "badge draft",
7 Maturity.DraftSynced => "badge synced",
8 Maturity.DraftShared => "badge shared",
9 Maturity.Proposed => "badge proposed",
10 Maturity.Accepted => "badge accepted",
11 _ => "badge"
12 };
13}
14
15<span class="@CssClass">@Value</span>
16
+ src/Catena.Server/Components/Shared/OpsList.razor
1@code {
2 [Parameter] public required List<Operation> Operations { get; set; }
3
4 private static string Prefix(OpType type) => type switch
5 {
6 OpType.Insert => "+",
7 OpType.Modify => "~",
8 OpType.Delete => "-",
9 OpType.Rename or OpType.Move => ">",
10 _ => "?"
11 };
12
13 private static string FormatBytes(long bytes) => bytes switch
14 {
15 < 1024 => $"{bytes} B",
16 < 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
17 _ => $"{bytes / (1024.0 * 1024.0):F1} MB"
18 };
19}
20
21<table class="ops-table">
22 <thead>
23 <tr><th></th><th>File</th><th>Size</th></tr>
24 </thead>
25 <tbody>
26 @foreach (var op in Operations)
27 {
28 var size = op.EndByte - op.StartByte;
29 <tr class="op-@op.Type.ToString().ToLower()">
30 <td>@Prefix(op.Type)</td>
31 <td>
32 @op.File
33 @if (op.NewPath is not null)
34 {
35 <span> &rarr; @op.NewPath</span>
36 }
37 </td>
38 <td>@(size > 0 ? FormatBytes(size) : "")</td>
39 </tr>
40 }
41 </tbody>
42</table>
43
+ src/Catena.Server/Components/_Imports.razor
1@using Microsoft.AspNetCore.Components
2@using Microsoft.AspNetCore.Components.Authorization
3@using Microsoft.AspNetCore.Components.Forms
4@using Microsoft.AspNetCore.Components.Routing
5@using Microsoft.AspNetCore.Components.Web
6@using Catena.Shared.Models
7@using Catena.Shared.Dtos
8@using Catena.Storage
9@using Microsoft.AspNetCore.Http
10@using Microsoft.JSInterop
11@using Catena.Server.Components.Shared
12
+ src/Catena.Server/Program.cs
1using Catena.Server;
2using Catena.Server.Components;
3using Catena.Storage;
4
5var builder = WebApplication.CreateBuilder(args);
6
7builder.Services.AddCatena(builder.Configuration);
8builder.Services.AddRazorComponents()
9 .AddInteractiveServerComponents();
10builder.Services.AddCascadingAuthenticationState();
11builder.Services.AddHttpContextAccessor();
12
13var app = builder.Build();
14
15app.UseStaticFiles();
16
17var authEnabled = app.Configuration.GetValue("Catena:AuthEnabled", true);
18if (authEnabled)
19{
20 app.UseAuthentication();
21 app.UseAuthorization();
22}
23
24app.UseAntiforgery();
25
26app.MapCatenaApi();
27app.MapStaticAssets();
28app.MapRazorComponents<App>()
29 .AddInteractiveServerRenderMode();
30
31// Seed admin user on first run
32if (authEnabled)
33{
34 var userStore = app.Services.GetRequiredService<UserStore>();
35 if (!userStore.HasAnyUsers())
36 {
37 var (admin, key) = await userStore.CreateAsync("admin", Catena.Shared.Models.UserRole.Admin);
38 app.Logger.LogWarning("=== First run: Admin user created ===");
39 app.Logger.LogWarning("Admin API Key: {Key}", key);
40 app.Logger.LogWarning("Save this key — it will not be shown again.");
41 }
42}
43
44app.Run();
45
46public partial class Program { }
47
+ src/Catena.Server/Properties/launchSettings.json
1{
2 "$schema": "https://json.schemastore.org/launchsettings.json",
3 "profiles": {
4 "http": {
5 "commandName": "Project",
6 "dotnetRunMessages": true,
7 "launchBrowser": false,
8 "applicationUrl": "http://localhost:5000",
9 "environmentVariables": {
10 "ASPNETCORE_ENVIRONMENT": "Development"
11 }
12 },
13 "https": {
14 "commandName": "Project",
15 "dotnetRunMessages": true,
16 "launchBrowser": false,
17 "applicationUrl": "https://localhost:7078;http://localhost:5000",
18 "environmentVariables": {
19 "ASPNETCORE_ENVIRONMENT": "Development"
20 }
21 }
22 }
23}
24
+ src/Catena.Server/ServiceExtensions.cs
1using Catena.Server.Api;
2using Catena.Server.Auth;
3using Catena.Storage;
4using Microsoft.AspNetCore.Authentication;
5using Microsoft.AspNetCore.Authentication.Cookies;
6
7namespace Catena.Server;
8
9public static class ServiceExtensions
10{
11 public static IServiceCollection AddCatena(this IServiceCollection services, IConfiguration config)
12 {
13 var dataDir = config.GetValue<string>("Catena:DataDir")
14 ?? Path.Combine(AppContext.BaseDirectory, "catena-data");
15
16 var projectsDir = Path.Combine(dataDir, "projects");
17 var usersDir = Path.Combine(dataDir, "users");
18
19 services.AddSingleton(new ProjectStore(projectsDir));
20 services.AddSingleton(new PatchStore(projectsDir));
21 services.AddSingleton(new TrunkState(projectsDir));
22 services.AddSingleton(new ReleaseStore(projectsDir));
23 services.AddSingleton(new UserStore(usersDir));
24 services.AddSingleton(new ReviewStore(projectsDir));
25 services.AddSingleton(new WebhookStore(projectsDir));
26 services.AddSingleton<CatenaEvents>();
27
28 var authEnabled = config.GetValue("Catena:AuthEnabled", true);
29
30 if (authEnabled)
31 {
32 services.AddAuthentication(options =>
33 {
34 options.DefaultScheme = "Combined";
35 options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
36 })
37 .AddScheme<AuthenticationSchemeOptions, ApiKeyAuthHandler>(ApiKeyAuthHandler.SchemeName, null)
38 .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
39 {
40 options.LoginPath = "/login";
41 options.Cookie.Name = "catena-session";
42 options.Events.OnRedirectToLogin = ctx =>
43 {
44 // API requests get 401, browser requests get redirect
45 if (ctx.Request.Headers.ContainsKey("X-Api-Key") ||
46 ctx.Request.Path.StartsWithSegments("/projects") ||
47 ctx.Request.Path.StartsWithSegments("/users") ||
48 ctx.Request.Path.StartsWithSegments("/auth"))
49 {
50 ctx.Response.StatusCode = 401;
51 return Task.CompletedTask;
52 }
53 ctx.Response.Redirect(ctx.RedirectUri);
54 return Task.CompletedTask;
55 };
56 })
57 .AddPolicyScheme("Combined", "Combined", options =>
58 {
59 options.ForwardDefaultSelector = ctx =>
60 {
61 if (ctx.Request.Headers.ContainsKey(ApiKeyAuthHandler.HeaderName))
62 return ApiKeyAuthHandler.SchemeName;
63 return CookieAuthenticationDefaults.AuthenticationScheme;
64 };
65 });
66
67 services.AddAuthorizationBuilder()
68 .AddPolicy("AdminOnly", p => p.RequireRole("Admin"))
69 .AddPolicy("MaintainerOrAdmin", p => p.RequireRole("Maintainer", "Admin"));
70 }
71
72 return services;
73 }
74
75 public static WebApplication MapCatenaApi(this WebApplication app)
76 {
77 app.MapProjectApi();
78 app.MapPatchApi();
79 app.MapTrunkApi();
80 app.MapOverlapApi();
81 app.MapReleaseApi();
82 app.MapAuthApi();
83 app.MapUserApi();
84 app.MapReviewApi();
85 app.MapWebhookApi();
86 return app;
87 }
88}
89
+ src/Catena.Server/appsettings.Development.json
1{
2 "Logging": {
3 "LogLevel": {
4 "Default": "Information",
5 "Microsoft.AspNetCore": "Warning"
6 }
7 }
8}
9
+ src/Catena.Server/appsettings.json
1{
2 "Logging": {
3 "LogLevel": {
4 "Default": "Information",
5 "Microsoft.AspNetCore": "Warning"
6 }
7 },
8 "AllowedHosts": "*"
9}
10
+ src/Catena.Server/wwwroot/css/app.css
1@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
2
3*{margin:0;padding:0;box-sizing:border-box}
4
5:root{
6 --bg:#09090b;--surface:#111113;--surface2:#19191d;--surface3:#222228;
7 --border:#27272a;--border-h:#3f3f46;
8 --text:#fafafa;--text2:#a1a1aa;--text3:#52525b;
9 --accent:#22c55e;--accent-dim:#052e16;
10 --proposed:#f59e0b;--proposed-dim:#451a03;
11 --draft:#3b82f6;--draft-dim:#172554;
12 --release:#a78bfa;--release-dim:#1e1245;
13 --hotfix:#f97316;--hotfix-dim:#431407;
14 --danger:#ef4444;--danger-dim:#450a0a;
15 --font:'Outfit',sans-serif;--mono:'JetBrains Mono',monospace;
16 --sidebar-w:220px;
17}
18
19body{background:var(--bg);color:var(--text);font-family:var(--font);font-weight:400;min-height:100vh}
20
21/* ===== LANDING PAGE ===== */
22.landing{min-height:100vh;display:flex;flex-direction:column;overflow:auto}
23.landing-hero{text-align:center;padding:120px 24px 80px;background:radial-gradient(ellipse at 50% 0%,rgba(34,197,94,.06) 0%,transparent 70%)}
24.landing-brand{font-size:4rem;font-weight:700;letter-spacing:-2px;color:var(--text)}
25.landing-brand .dot{color:var(--accent)}
26.landing-tagline{font-size:1.4rem;font-weight:300;color:var(--text2);margin-top:16px;letter-spacing:.5px}
27.landing-sub{font-size:.95rem;color:var(--text3);margin-top:8px;font-family:var(--mono)}
28.landing-actions{margin-top:40px;display:flex;justify-content:center;gap:16px}
29.btn-primary{display:inline-block;padding:12px 32px;background:var(--accent);color:#000;font-weight:600;font-size:.95rem;border-radius:6px;text-decoration:none;transition:opacity .15s}
30.btn-primary:hover{opacity:.9;text-decoration:none}
31.landing-projects{max-width:900px;margin:0 auto;padding:0 24px 60px}
32.landing-projects h2{font-size:.8rem;text-transform:uppercase;letter-spacing:2px;color:var(--text3);margin-bottom:20px}
33.project-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
34.project-card{display:block;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:20px;text-decoration:none;transition:border-color .15s,background .15s}
35.project-card:hover{border-color:var(--border-h);background:var(--surface2);text-decoration:none}
36.project-card-name{font-size:1.1rem;font-weight:500;color:var(--text);margin-bottom:8px}
37.project-card-meta{display:flex;justify-content:space-between;align-items:center}
38.project-card-meta code{font-family:var(--mono);font-size:.75rem;color:var(--text3)}
39.project-card-meta span{font-size:.75rem;color:var(--text3)}
40.landing-features{max-width:900px;margin:0 auto;padding:40px 24px 80px;display:grid;grid-template-columns:repeat(3,1fr);gap:24px;border-top:1px solid var(--border)}
41.feature{padding:24px}
42.feature-icon{width:40px;height:40px;border-radius:8px;background:var(--accent-dim);border:1px solid #166534;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:1.2rem;color:var(--accent);margin-bottom:16px}
43.feature h3{font-size:1rem;font-weight:500;margin-bottom:8px}
44.feature p{font-size:.85rem;color:var(--text2);line-height:1.5}
45.landing-footer{text-align:center;padding:24px;color:var(--text3);font-size:.8rem;border-top:1px solid var(--border)}
46.login-page{max-width:400px;margin:0 auto;padding:120px 24px}
47.login-brand{display:block;font-size:2rem;font-weight:700;letter-spacing:-1px;color:var(--text);text-decoration:none;margin-bottom:40px}
48.login-brand .dot{color:var(--accent)}
49.login-page h1{font-size:1.2rem;font-weight:400;color:var(--text2);margin-bottom:24px}
50
51/* ===== APP SHELL ===== */
52.app-shell{display:flex;height:100vh;overflow:hidden}
53
54/* ===== SIDEBAR ===== */
55.sidebar{width:var(--sidebar-w);min-width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh}
56.sidebar-brand{padding:20px 20px 16px;font-size:15px;font-weight:600;letter-spacing:1px;color:var(--text);border-bottom:1px solid var(--border);text-decoration:none;display:block}
57.sidebar-brand span{color:var(--accent)}
58.project-select{margin:12px 12px 8px;padding:8px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;color:var(--text);font-family:var(--font);font-size:13px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;text-decoration:none}
59.project-select::after{content:'';width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid var(--text3)}
60.nav{padding:8px 0;flex:1}
61.nav-item{display:flex;align-items:center;gap:10px;padding:9px 20px;font-size:13px;color:var(--text2);cursor:pointer;transition:all .15s;border-left:3px solid transparent;text-decoration:none}
62.nav-item:hover{background:var(--surface2);color:var(--text);text-decoration:none}
63.nav-item.active{color:var(--text);background:var(--surface2);border-left-color:var(--accent)}
64.nav-item .nav-icon{width:16px;height:16px;display:flex;align-items:center;justify-content:center;font-size:14px;opacity:.6}
65.nav-item.active .nav-icon{opacity:1}
66.nav-badge{margin-left:auto;font-size:10px;font-family:var(--mono);padding:2px 6px;border-radius:4px;background:var(--danger-dim);color:var(--danger)}
67.sidebar-footer{padding:12px 20px;border-top:1px solid var(--border);font-size:11px;color:var(--text3);display:flex;align-items:center;gap:8px}
68.user-dot{width:24px;height:24px;border-radius:50%;background:var(--accent-dim);border:1.5px solid var(--accent);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500;color:var(--accent)}
69
70/* ===== MAIN ===== */
71.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden}
72
73/* ===== TOP BAR ===== */
74.topbar{height:52px;min-height:52px;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 24px;gap:16px}
75.topbar-title{font-size:15px;font-weight:500}
76.topbar-sub{font-size:12px;color:var(--text3);font-family:var(--mono)}
77.topbar-actions{margin-left:auto;display:flex;gap:8px}
78.btn{font-family:var(--font);font-size:12px;font-weight:500;padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text2);cursor:pointer;transition:all .15s;text-decoration:none;display:inline-block}
79.btn:hover{background:var(--surface3);color:var(--text);border-color:var(--border-h);text-decoration:none}
80.btn-accent{background:var(--accent-dim);color:var(--accent);border-color:#166534}
81.btn-accent:hover{background:#0a3d1a}
82.btn-danger{background:var(--danger-dim);color:var(--danger);border-color:#991b1b}
83
84/* ===== DASHBOARD ===== */
85.dashboard-content{flex:1;overflow-y:auto;padding:24px}
86.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:24px}
87.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px 18px}
88.stat-label{font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:.8px;margin-bottom:6px}
89.stat-value{font-size:26px;font-weight:600}
90.stat-value.green{color:var(--accent)}
91.stat-value.amber{color:var(--proposed)}
92.stat-value.blue{color:var(--draft)}
93.stat-value.red{color:var(--danger)}
94.section-title{font-size:12px;font-weight:500;color:var(--text3);text-transform:uppercase;letter-spacing:1px;margin-bottom:12px}
95.activity-list{margin-bottom:24px}
96.activity-item{display:flex;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid var(--border);font-size:13px}
97.activity-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
98.activity-text{flex:1;color:var(--text2)}
99.activity-text strong{color:var(--text);font-weight:500}
100.activity-time{font-size:11px;color:var(--text3);font-family:var(--mono)}
101
102/* ===== PATCHES ===== */
103.patches-layout{flex:1;display:grid;grid-template-columns:320px minmax(0,1fr);overflow:hidden}
104.patch-list{border-right:1px solid var(--border);overflow-y:auto}
105.patch-filters{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;gap:6px}
106.filter-pill{font-family:var(--font);font-size:11px;padding:4px 10px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text3);cursor:pointer;transition:all .15s}
107.filter-pill:hover{color:var(--text2);border-color:var(--border-h)}
108.filter-pill.active{background:var(--accent-dim);color:var(--accent);border-color:#166534}
109.patch-item{padding:12px 14px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;border-left:3px solid transparent}
110.patch-item:hover{background:var(--surface)}
111.patch-item.selected{background:var(--surface);border-left-color:var(--accent)}
112.patch-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
113.patch-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px}
114.patch-status{font-size:10px;font-family:var(--mono);padding:2px 7px;border-radius:4px}
115.status-accepted{background:var(--accent-dim);color:var(--accent)}
116.status-proposed{background:var(--proposed-dim);color:var(--proposed)}
117.status-draft{background:var(--draft-dim);color:var(--draft)}
118.status-conflict{background:var(--danger-dim);color:var(--danger)}
119.patch-meta{font-size:11px;color:var(--text3)}
120.patch-overlap{font-size:10px;color:var(--danger);margin-top:4px}
121.patch-dep{font-size:10px;color:var(--draft);margin-top:3px}
122.patch-detail{overflow-y:auto;display:flex;flex-direction:column}
123.patch-detail-header{padding:20px 24px;border-bottom:1px solid var(--border)}
124.pd-title{font-size:18px;font-weight:500;margin-bottom:4px}
125.pd-meta{font-size:12px;color:var(--text3);margin-bottom:12px}
126.pd-tags{display:flex;gap:6px;flex-wrap:wrap}
127.pd-tag{font-size:10px;font-family:var(--mono);padding:3px 8px;border-radius:4px}
128.patch-tabs{display:flex;border-bottom:1px solid var(--border)}
129.patch-tab{padding:10px 20px;font-size:13px;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}
130.patch-tab:hover{color:var(--text2)}
131.patch-tab.active{color:var(--text);border-bottom-color:var(--accent)}
132.patch-tab-content{flex:1;padding:20px 24px;overflow-y:auto}
133
134/* Diff */
135.diff-file{margin-bottom:16px}
136.diff-file-header{font-size:12px;font-weight:500;font-family:var(--mono);padding:8px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:8px 8px 0 0;color:var(--text2)}
137.diff-lines{border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;overflow:hidden;font-family:var(--mono);font-size:12px;line-height:1.7}
138.diff-line{padding:1px 12px;white-space:pre}
139.diff-line .ln{display:inline-block;width:35px;text-align:right;margin-right:16px;color:var(--text3);user-select:none;opacity:.5}
140.diff-line .ln.ln-clickable{cursor:pointer;transition:opacity .1s}
141.diff-line .ln.ln-clickable:hover{opacity:1;color:var(--accent)}
142.diff-line.ctx{color:var(--text3)}
143.diff-line.add{background:#05200e;color:#6ee7b7}
144.diff-line.del{background:#200505;color:#fca5a5}
145.diff-comment{margin:8px 0 8px 24px;padding:10px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:8px}
146.diff-comment-head{display:flex;align-items:center;gap:8px;margin-bottom:4px}
147.diff-comment-avatar{width:20px;height:20px;border-radius:50%;background:var(--draft-dim);border:1px solid var(--draft);display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:500;color:var(--draft)}
148.diff-comment-author{font-size:12px;font-weight:500}
149.diff-comment-time{font-size:10px;color:var(--text3)}
150.diff-comment-text{font-size:12px;color:var(--text2);font-family:var(--font)}
151
152/* ===== TIMELINE ===== */
153.tl-split{flex:1;display:flex;flex-direction:column;overflow:hidden}
154.tl-wip-zone{flex:0 1 auto;overflow:auto;min-height:200px}
155.tl-divider{height:1px;background:linear-gradient(90deg,transparent,var(--border) 20%,var(--border) 80%,transparent);flex-shrink:0}
156.tl-trunk-zone{flex:1 1 0;overflow-y:auto;overflow-x:hidden;min-height:0}
157.tl-graph{position:relative;padding:20px}
158.tl-edges{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0}
159.tl-detail-panel{position:fixed;bottom:0;right:0;width:45vw;height:45vh;overflow:auto;background:var(--surface);border-left:1px solid var(--border);border-top:1px solid var(--border);border-radius:8px 0 0 0;z-index:10;padding:20px;box-shadow:-4px -4px 20px rgba(0,0,0,.4)}
160.tl-zone-label{font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);font-weight:400;margin-bottom:8px}
161.tl-node{position:absolute;border-radius:8px;cursor:pointer;z-index:2;display:flex;align-items:center;gap:8px;white-space:nowrap}
162.tl-node:hover{z-index:3;filter:brightness(1.15)}
163.tl-node-accepted{padding:6px 12px;background:var(--accent-dim);border:1.5px solid #166534}
164.tl-node-proposed{padding:8px 12px;background:var(--surface);border:1px solid var(--border)}
165.tl-node-draft{padding:8px 12px;background:var(--surface);border:1px solid var(--border)}
166.tl-node-release{padding:4px 14px;background:var(--release-dim);border:1.5px solid var(--release);border-radius:14px}
167.tl-node-hotfix{padding:6px 12px;background:var(--hotfix-dim);border:1.5px solid #9a3412}
168.tl-node-next{padding:4px 10px;border:1.5px dashed var(--text3);background:transparent;border-radius:6px}
169.tl-nd{width:11px;height:11px;border-radius:50%;flex-shrink:0;border:2px solid}
170.tl-nd-accepted{background:var(--accent-dim);border-color:var(--accent)}
171.tl-nd-proposed{background:var(--amber-dim, #451a03);border-color:var(--amber, #f59e0b)}
172.tl-nd-synced{background:var(--blue-dim, #172554);border-color:var(--blue, #3b82f6);border-style:dashed}
173.tl-nd-shared{background:var(--blue-dim, #172554);border-color:var(--blue, #3b82f6)}
174.tl-nd-hotfix{background:var(--hotfix-dim);border-color:var(--hotfix)}
175.tl-nn{font-size:12px;font-weight:500}
176.tl-ns{font-size:10px;color:var(--text3);display:block;margin-top:1px}
177.tl-ndep{font-size:9px;color:var(--draft);display:block;margin-top:2px}
178.tl-ni{display:flex;flex-direction:column}
179.tl-legend{display:flex;gap:12px;font-size:10px;color:var(--text2);margin-left:auto}
180.tl-legend i{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:3px;vertical-align:middle}
181.tl-svg{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:1}
182.tl-zone-label{position:absolute;top:10px;font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text3);font-weight:400;z-index:3}
183.tl-node{position:absolute;display:flex;align-items:center;gap:8px;z-index:2;cursor:pointer;transition:transform .1s}
184.tl-node:hover{transform:scale(1.03)}
185.tl-node-left{flex-direction:row-reverse}
186.tl-dot{width:14px;height:14px;border-radius:50%;flex-shrink:0;border:2px solid}
187.dot-accepted{background:var(--accent-dim);border-color:var(--accent)}
188.dot-proposed{background:var(--proposed-dim);border-color:var(--proposed)}
189.dot-synced{background:var(--draft-dim);border-color:var(--draft);border-style:dashed}
190.dot-shared{background:var(--draft-dim);border-color:var(--draft)}
191.dot-hotfix{background:var(--hotfix-dim);border-color:var(--hotfix)}
192.tl-info{display:flex;flex-direction:column;gap:1px}
193.tl-name{font-size:12px;font-weight:500;white-space:nowrap}
194.tl-meta{font-size:10px;color:var(--text3);white-space:nowrap}
195.release-pill{font-size:10px;font-weight:500;font-family:var(--mono);padding:3px 10px;border-radius:10px;background:var(--release-dim);border:1.5px solid var(--release);color:var(--release);white-space:nowrap}
196.tl-wip-card{position:absolute;display:flex;align-items:flex-start;gap:8px;padding:7px 11px;background:var(--surface);border:1px solid var(--border);border-radius:8px;cursor:pointer;transition:border-color .15s,background .15s;z-index:2;max-width:280px}
197.tl-wip-card:hover{border-color:var(--border-h);background:var(--surface2)}
198.tl-wip-info{display:flex;flex-direction:column;gap:1px;flex:1}
199.tl-wip-title{font-size:12px;font-weight:500;white-space:nowrap}
200.tl-wip-sub{font-size:10px;color:var(--text3);white-space:nowrap}
201.tl-wip-overlap-badge{font-size:9px;font-family:var(--mono);padding:2px 6px;border-radius:4px;background:var(--danger-dim);color:var(--danger);position:absolute;top:5px;right:8px}
202
203/* ===== FILES ===== */
204.files-layout{flex:1;display:grid;grid-template-columns:280px minmax(0,1fr);overflow:hidden}
205.file-tree{border-right:1px solid var(--border);overflow-y:auto;padding:12px 0}
206.file-item{padding:6px 20px;font-size:13px;color:var(--text2);cursor:pointer;font-family:var(--mono);display:flex;align-items:center;gap:8px}
207.file-item:hover{background:var(--surface);color:var(--text)}
208.file-item.selected{background:var(--surface);color:var(--text)}
209.file-item.folder{color:var(--text3);font-weight:500;font-family:var(--font)}
210.file-indent{padding-left:40px}
211.file-indent2{padding-left:60px}
212.file-icon{font-size:12px;opacity:.5}
213.file-viewer{overflow-y:auto;padding:20px 24px}
214.file-viewer-header{font-size:14px;font-weight:500;font-family:var(--mono);margin-bottom:4px}
215.file-viewer-meta{font-size:11px;color:var(--text3);margin-bottom:16px}
216.file-code{font-family:var(--mono);font-size:12px;line-height:1.7;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;overflow-x:auto;white-space:pre}
217.file-code .ln{color:var(--text3);display:inline-block;width:30px;text-align:right;margin-right:16px;user-select:none}
218
219/* ===== RELEASES ===== */
220.releases-content{flex:1;overflow-y:auto;padding:24px}
221.release-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:18px 20px;margin-bottom:12px;display:flex;align-items:center;gap:16px}
222.release-version{font-family:var(--mono);font-size:16px;font-weight:500;color:var(--release);min-width:80px}
223.release-info{flex:1}
224.release-date{font-size:12px;color:var(--text3)}
225.release-patches{font-size:12px;color:var(--text2);margin-top:2px}
226.release-tag{font-size:10px;font-family:var(--mono);padding:3px 8px;border-radius:4px;background:var(--release-dim);color:var(--release)}
227
228/* ===== SETTINGS ===== */
229.settings-content{flex:1;overflow-y:auto;padding:24px;max-width:640px}
230.setting-section{margin-bottom:28px}
231.setting-section-title{font-size:14px;font-weight:500;margin-bottom:14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
232.setting-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;font-size:13px}
233.setting-label{color:var(--text2)}
234.setting-value{font-family:var(--mono);font-size:12px;color:var(--text3);padding:4px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px}
235.user-row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px}
236.user-role{margin-left:auto;font-size:11px;padding:3px 8px;border-radius:4px;font-family:var(--mono)}
237.role-admin{background:var(--danger-dim);color:var(--danger)}
238.role-maintainer{background:var(--proposed-dim);color:var(--proposed)}
239.role-dev{background:var(--accent-dim);color:var(--accent)}
240
241/* ===== FORMS ===== */
242form{display:flex;flex-direction:column;gap:.5rem;max-width:400px}
243label{color:var(--text2);font-size:.85rem}
244input[type="password"],input[type="text"],select{padding:.5rem;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--font)}
245form button[type="submit"]{padding:.5rem 1rem;background:var(--accent);color:#000;border:none;border-radius:4px;cursor:pointer;font-weight:600}
246.error{color:var(--danger);margin-bottom:.5rem;font-size:.9rem}
247
+ src/Catena.Server/wwwroot/favicon.svg
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2 <rect width="32" height="32" rx="6" fill="#09090b"/>
3 <circle cx="16" cy="16" r="13" fill="#fafafa"/>
4 <rect x="20" y="9" width="12" height="14" fill="#09090b"/>
5 <circle cx="16" cy="16" r="9" fill="#22c55e"/>
6</svg>
7
+ src/Catena.Server/wwwroot/js/timeline.js
1window.catenaTimeline = {
2 drawEdgesIn: function (containerId, svgId, edgesJson) {
3 const svg = document.getElementById(svgId);
4 const container = document.getElementById(containerId);
5 if (!svg || !container) return;
6
7 while (svg.firstChild) svg.removeChild(svg.firstChild);
8 svg.setAttribute('width', container.scrollWidth);
9 svg.setAttribute('height', container.scrollHeight);
10
11 const edges = JSON.parse(edgesJson);
12 const cr = container.getBoundingClientRect();
13
14 function nr(id) {
15 const el = document.getElementById('node-' + id);
16 if (!el) return null;
17 const sl = container.scrollLeft, st = container.scrollTop;
18 const r = el.getBoundingClientRect();
19 // Use the dot (.tl-nd) as anchor point for edges
20 // If no dot, use left+16px as synthetic anchor (matches dot position in other nodes)
21 const dot = el.querySelector('.tl-nd');
22 const anchor = dot ? dot.getBoundingClientRect() : {
23 left: r.left + 12, top: r.top, width: 11, height: r.height,
24 right: r.left + 23, bottom: r.bottom
25 };
26 return {
27 // Dot center for edge connections
28 cx: anchor.left + anchor.width / 2 - cr.left + sl,
29 cy: anchor.top + anchor.height / 2 - cr.top + st,
30 // Node edges for horizontal routing
31 t: r.top - cr.top + st,
32 b: r.bottom - cr.top + st,
33 l: r.left - cr.left + sl,
34 r: r.right - cr.left + sl
35 };
36 }
37
38 edges.forEach(e => {
39 const a = nr(e.from), b = nr(e.to);
40 if (!a || !b) return;
41
42 const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
43 const route = e.route || '';
44 let d = '';
45
46 if (route === 'h') {
47 // Strictly horizontal: use average Y so both ends are on same level
48 const y = (a.cy + b.cy) / 2;
49 const x1 = a.cx < b.cx ? a.r : a.l;
50 const x2 = a.cx < b.cx ? b.l : b.r;
51 d = `M${x1} ${y} L${x2} ${y}`;
52 } else if (route === 'hv') {
53 d = `M${a.r} ${a.cy} L${b.cx} ${a.cy} L${b.cx} ${b.b}`;
54 } else if (route === 'vu') {
55 d = `M${a.cx} ${a.t} L${a.cx} ${b.cy} L${b.r} ${b.cy}`;
56 } else {
57 if (Math.abs(a.cx - b.cx) < 20) {
58 const y1 = a.cy < b.cy ? a.b : a.t;
59 const y2 = a.cy < b.cy ? b.t : b.b;
60 d = `M${a.cx} ${y1} L${b.cx} ${y2}`;
61 } else {
62 const y1 = a.cy < b.cy ? a.b : a.t;
63 const y2 = a.cy < b.cy ? b.t : b.b;
64 const my = (y1 + y2) / 2;
65 d = `M${a.cx} ${y1} C${a.cx} ${my}, ${b.cx} ${my}, ${b.cx} ${y2}`;
66 }
67 }
68
69 path.setAttribute('d', d);
70 path.setAttribute('stroke', e.color);
71 path.setAttribute('stroke-width', e.width || 1.5);
72 path.setAttribute('fill', 'none');
73 path.setAttribute('stroke-linecap', 'round');
74 path.setAttribute('stroke-linejoin', 'round');
75 if (e.dash) path.setAttribute('stroke-dasharray', e.dash);
76 svg.appendChild(path);
77 });
78 },
79
80 drawOverlapIn: function (containerId, svgId, nodeIdA, nodeIdB) {
81 const svg = document.getElementById(svgId);
82 const container = document.getElementById(containerId);
83 if (!svg || !container) return;
84
85 const elA = document.getElementById('node-' + nodeIdA);
86 const elB = document.getElementById('node-' + nodeIdB);
87 if (!elA || !elB) return;
88
89 const cr = container.getBoundingClientRect();
90 const rA = elA.getBoundingClientRect();
91 const rB = elB.getBoundingClientRect();
92 const sl = container.scrollLeft, st = container.scrollTop;
93
94 const hY = rA.top + rA.height / 2 - cr.top + st;
95 const hX1 = rA.right - cr.left + sl;
96 const hX2 = rB.left - cr.left + sl;
97
98 const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
99 hLine.setAttribute('x1', hX1); hLine.setAttribute('y1', hY);
100 hLine.setAttribute('x2', hX2); hLine.setAttribute('y2', hY);
101 hLine.setAttribute('stroke', '#ef4444');
102 hLine.setAttribute('stroke-width', '2');
103 hLine.setAttribute('stroke-linecap', 'round');
104 svg.appendChild(hLine);
105
106 const midX = (hX1 + hX2) / 2;
107 const badgeY = hY + 28;
108 const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
109 vLine.setAttribute('x1', midX); vLine.setAttribute('y1', hY);
110 vLine.setAttribute('x2', midX); vLine.setAttribute('y2', badgeY - 10);
111 vLine.setAttribute('stroke', '#ef4444');
112 vLine.setAttribute('stroke-width', '2');
113 vLine.setAttribute('stroke-linecap', 'round');
114 svg.appendChild(vLine);
115
116 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
117 rect.setAttribute('x', midX - 28); rect.setAttribute('y', badgeY - 10);
118 rect.setAttribute('width', 56); rect.setAttribute('height', 20);
119 rect.setAttribute('rx', 10);
120 rect.setAttribute('fill', '#450a0a'); rect.setAttribute('stroke', '#991b1b');
121 rect.setAttribute('stroke-width', 1);
122 svg.appendChild(rect);
123
124 const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
125 text.setAttribute('x', midX); text.setAttribute('y', badgeY + 4);
126 text.setAttribute('text-anchor', 'middle');
127 text.setAttribute('font-size', '9'); text.setAttribute('font-weight', '500');
128 text.setAttribute('fill', '#ef4444');
129 text.setAttribute('font-family', "'JetBrains Mono', monospace");
130 text.textContent = 'overlap';
131 svg.appendChild(text);
132 }
133};
134
+ src/Catena.Shared/Catena.Shared.csproj
1<Project Sdk="Microsoft.NET.Sdk">
2
3 <PropertyGroup>
4 <TargetFramework>net10.0</TargetFramework>
5 <ImplicitUsings>enable</ImplicitUsings>
6 <Nullable>enable</Nullable>
7 </PropertyGroup>
8
9</Project>
10
+ src/Catena.Shared/Dtos/BulkDownloadRequest.cs
1namespace Catena.Shared.Dtos;
2
3public sealed record BulkDownloadRequest(List<string> Files);
4
+ src/Catena.Shared/Dtos/CreatePatchRequest.cs
1using Catena.Shared.Models;
2
3namespace Catena.Shared.Dtos;
4
5public sealed record CreatePatchRequest
6{
7 public required string Author { get; init; }
8 public required string Description { get; init; }
9 public Maturity Maturity { get; init; } = Maturity.DraftLocal;
10 public List<string> Dependencies { get; init; } = [];
11 public List<string> Targets { get; init; } = ["trunk"];
12 public string? BaseHash { get; init; }
13 public required List<FileOperation> Ops { get; init; }
14}
15
16public sealed record FileOperation
17{
18 public required OpType Type { get; init; }
19 public required string File { get; init; }
20 public string? ContentBase64 { get; init; }
21 public string? NewPath { get; init; }
22}
23
+ src/Catena.Shared/Dtos/CreateProjectRequest.cs
1namespace Catena.Shared.Dtos;
2
3public sealed record CreateProjectRequest(string Name, bool IsPublic = false);
4
+ src/Catena.Shared/Dtos/CreateReleaseRequest.cs
1namespace Catena.Shared.Dtos;
2
3public sealed record CreateReleaseRequest(string Version);
4
+ src/Catena.Shared/Dtos/FileContentResponse.cs
1namespace Catena.Shared.Dtos;
2
3public sealed record FileContentResponse
4{
5 public required string Path { get; init; }
6 public required string ContentBase64 { get; init; }
7}
8
+ src/Catena.Shared/Dtos/FileTreeResponse.cs
1namespace Catena.Shared.Dtos;
2
3public sealed record FileTreeEntry
4{
5 public required string Name { get; init; }
6 public required string Path { get; init; }
7 public required bool IsDirectory { get; init; }
8 public List<FileTreeEntry> Children { get; init; } = [];
9}
10
+ src/Catena.Shared/Dtos/OverlapResponse.cs
1using Catena.Shared.Overlap;
2
3namespace Catena.Shared.Dtos;
4
5public sealed record OverlapReportResponse
6{
7 public required string PatchIdA { get; init; }
8 public required string PatchIdB { get; init; }
9 public required bool HasConflicts { get; init; }
10 public required List<OverlapItemResponse> Items { get; init; }
11}
12
13public sealed record OverlapItemResponse
14{
15 public required OverlapKind Kind { get; init; }
16 public required string FileA { get; init; }
17 public required string FileB { get; init; }
18 public required int OpIndexA { get; init; }
19 public required int OpIndexB { get; init; }
20 public string? Reason { get; init; }
21}
22
+ src/Catena.Shared/Dtos/PatchDiffResponse.cs
1using Catena.Shared.Models;
2
3namespace Catena.Shared.Dtos;
4
5public sealed record PatchDiffResponse
6{
7 public required string PatchId { get; init; }
8 public required string Author { get; init; }
9 public required string Description { get; init; }
10 public required DateTime Timestamp { get; init; }
11 public required List<DiffEntry> Entries { get; init; }
12}
13
14public sealed record DiffEntry
15{
16 public required OpType Type { get; init; }
17 public required string File { get; init; }
18 public long Bytes { get; init; }
19 public int LinesAdded { get; init; }
20 public int LinesDeleted { get; init; }
21 public string? NewPath { get; init; }
22}
23
+ src/Catena.Shared/Dtos/PatchResponse.cs
1using Catena.Shared.Models;
2
3namespace Catena.Shared.Dtos;
4
5public sealed record PatchResponse
6{
7 public required string Id { get; init; }
8 public required string Author { get; init; }
9 public required string ProjectId { get; init; }
10 public required Maturity Maturity { get; init; }
11 public required DateTime Timestamp { get; init; }
12 public required string Description { get; init; }
13 public required List<string> Dependencies { get; init; }
14 public required List<string> Targets { get; init; }
15 public required int OpCount { get; init; }
16}
17
+ src/Catena.Shared/Dtos/ProjectResponse.cs
1namespace Catena.Shared.Dtos;
2
3public sealed record ProjectResponse(string Id, string Name, bool IsPublic, DateTime CreatedAt);
4
+ src/Catena.Shared/Dtos/ReleaseResponse.cs
1namespace Catena.Shared.Dtos;
2
3public sealed record ReleaseResponse
4{
5 public required string Version { get; init; }
6 public required string ProjectId { get; init; }
7 public required DateTime CreatedAt { get; init; }
8 public required int FileCount { get; init; }
9 public required string TrunkHash { get; init; }
10}
11
+ src/Catena.Shared/Dtos/RevertResponse.cs
1namespace Catena.Shared.Dtos;
2
3public sealed record RevertResponse
4{
5 public required string OriginalPatchId { get; init; }
6 public required PatchResponse RevertPatch { get; init; }
7}
8
+ src/Catena.Shared/Dtos/ReviewDtos.cs
1using Catena.Shared.Models;
2
3namespace Catena.Shared.Dtos;
4
5public sealed record CreateReviewRequest(string PatchId, string? FilePath = null, int? LineNumber = null, string? Comment = null);
6
7public sealed record UpdateReviewRequest(ReviewStatus Status, string Comment);
8
9public sealed record ReviewResponse(
10 string Id, string PatchId, string ReviewerId, string ReviewerName,
11 ReviewStatus Status, string Comment, string? FilePath, int? LineNumber,
12 DateTime CreatedAt, DateTime? UpdatedAt);
13
+ src/Catena.Shared/Dtos/TrunkStateResponse.cs
1namespace Catena.Shared.Dtos;
2
3public sealed record TrunkStateResponse
4{
5 public required Dictionary<string, string> Files { get; init; }
6 public required string Hash { get; init; }
7}
8
+ src/Catena.Shared/Dtos/UpdateMaturityRequest.cs
1using Catena.Shared.Models;
2
3namespace Catena.Shared.Dtos;
4
5public sealed record UpdateMaturityRequest(Maturity Maturity);
6
+ src/Catena.Shared/Dtos/UserDtos.cs
1using Catena.Shared.Models;
2
3namespace Catena.Shared.Dtos;
4
5public sealed record CreateUserRequest(string Name, UserRole Role);
6
7public sealed record UserResponse(string Id, string Name, UserRole Role, DateTime CreatedAt);
8
9public sealed record CreateUserResponse(string Id, string Name, UserRole Role, string ApiKey);
10
11public sealed record UpdateRoleRequest(UserRole Role);
12
13public sealed record LoginRequest(string ApiKey);
14
15public sealed record LoginResponse(string UserId, string Name, UserRole Role);
16
+ src/Catena.Shared/Dtos/WebhookDtos.cs
1namespace Catena.Shared.Dtos;
2
3public sealed record CreateWebhookRequest(string Event, string Url);
4
5public sealed record WebhookResponse(string Id, string ProjectId, string Event, string Url, DateTime CreatedAt);
6
+ src/Catena.Shared/Models/Maturity.cs
1namespace Catena.Shared.Models;
2
3public enum Maturity
4{
5 DraftLocal,
6 DraftSynced,
7 DraftShared,
8 Proposed,
9 Accepted
10}
11
+ src/Catena.Shared/Models/OpType.cs
1namespace Catena.Shared.Models;
2
3public enum OpType
4{
5 Insert,
6 Modify,
7 Delete,
8 Rename,
9 Move
10}
11
+ src/Catena.Shared/Models/Operation.cs
1namespace Catena.Shared.Models;
2
3public sealed record Operation
4{
5 public required OpType Type { get; init; }
6 public required string File { get; init; }
7 public long StartByte { get; init; }
8 public long EndByte { get; init; }
9 public byte[] Content { get; init; } = [];
10 public string? ContentHash { get; init; }
11 public int LinesAdded { get; init; }
12 public int LinesDeleted { get; init; }
13 public string? NewPath { get; init; } // for Rename/Move
14}
15
+ src/Catena.Shared/Models/Patch.cs
1namespace Catena.Shared.Models;
2
3public sealed record Patch
4{
5 public required string Id { get; init; }
6 public required string Author { get; init; }
7 public required string ProjectId { get; init; }
8 public List<string> Dependencies { get; init; } = [];
9 public List<string> Targets { get; init; } = ["trunk"];
10 public Maturity Maturity { get; init; } = Maturity.DraftLocal;
11 public List<Operation> Ops { get; init; } = [];
12 public string? BaseHash { get; init; }
13 public DateTime Timestamp { get; init; } = DateTime.UtcNow;
14 public DateTime? ProposedAt { get; init; }
15 public DateTime? AcceptedAt { get; init; }
16 public string Description { get; init; } = "";
17}
18
+ src/Catena.Shared/Models/Project.cs
1namespace Catena.Shared.Models;
2
3public sealed record Project
4{
5 public required string Id { get; init; }
6 public required string Name { get; init; }
7 public bool IsPublic { get; init; }
8 public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
9}
10
+ src/Catena.Shared/Models/Release.cs
1namespace Catena.Shared.Models;
2
3public sealed record Release
4{
5 public required string Version { get; init; }
6 public required string ProjectId { get; init; }
7 public required DateTime CreatedAt { get; init; }
8 public required Dictionary<string, string> Files { get; init; }
9 public required string TrunkHash { get; init; }
10 public List<string> PatchIds { get; init; } = [];
11 public string? BasePatchId { get; init; }
12}
13
+ src/Catena.Shared/Models/Review.cs
1namespace Catena.Shared.Models;
2
3public sealed record Review
4{
5 public required string Id { get; init; }
6 public required string PatchId { get; init; }
7 public required string ProjectId { get; init; }
8 public required string ReviewerId { get; init; }
9 public required string ReviewerName { get; init; }
10 public ReviewStatus Status { get; init; } = ReviewStatus.Pending;
11 public string Comment { get; init; } = "";
12 public string? FilePath { get; init; }
13 public int? LineNumber { get; init; }
14 public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
15 public DateTime? UpdatedAt { get; init; }
16}
17
+ src/Catena.Shared/Models/ReviewStatus.cs
1namespace Catena.Shared.Models;
2
3public enum ReviewStatus
4{
5 Pending,
6 Approved,
7 Rejected
8}
9
+ src/Catena.Shared/Models/User.cs
1namespace Catena.Shared.Models;
2
3public sealed record User
4{
5 public required string Id { get; init; }
6 public required string Name { get; init; }
7 public required UserRole Role { get; init; }
8 public required string ApiKeyHash { get; init; }
9 public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
10}
11
+ src/Catena.Shared/Models/UserRole.cs
1namespace Catena.Shared.Models;
2
3public enum UserRole
4{
5 Developer,
6 Maintainer,
7 Admin
8}
9
+ src/Catena.Shared/Models/Webhook.cs
1namespace Catena.Shared.Models;
2
3public sealed record Webhook
4{
5 public required string Id { get; init; }
6 public required string ProjectId { get; init; }
7 public required string Event { get; init; } // patch.proposed, patch.accepted, release.created, conflict.detected
8 public required string Url { get; init; }
9 public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
10}
11
+ src/Catena.Shared/Overlap/OffsetCalculator.cs
1using Catena.Shared.Models;
2
3namespace Catena.Shared.Overlap;
4
5/// <summary>
6/// Deterministic offset calculation for byte ranges.
7/// Given a prior operation that was applied, adjusts a target range.
8/// Returns null if the prior operation creates a conflict (overlaps the target range).
9/// </summary>
10public static class OffsetCalculator
11{
12 public record struct AdjustedRange(long Start, long End);
13
14 /// <summary>
15 /// Adjusts targetStart..targetEnd based on a prior operation.
16 /// Returns the shifted range, or null if there's a conflict (prior op overlaps target range).
17 /// </summary>
18 public static AdjustedRange? Adjust(Operation prior, long targetStart, long targetEnd)
19 {
20 return prior.Type switch
21 {
22 OpType.Insert => AdjustForInsert(prior.StartByte, prior.EndByte - prior.StartByte, targetStart, targetEnd),
23 OpType.Modify => AdjustForModify(prior.StartByte, prior.EndByte, targetStart, targetEnd),
24 OpType.Delete => AdjustForDelete(prior.StartByte, prior.EndByte, targetStart, targetEnd),
25 _ => new AdjustedRange(targetStart, targetEnd) // Rename/Move don't affect byte ranges
26 };
27 }
28
29 private static AdjustedRange? AdjustForInsert(long insertAt, long insertLength, long targetStart, long targetEnd)
30 {
31 if (insertLength == 0)
32 return new AdjustedRange(targetStart, targetEnd);
33
34 // Insert is entirely before target range → shift right
35 if (insertAt <= targetStart)
36 return new AdjustedRange(targetStart + insertLength, targetEnd + insertLength);
37
38 // Insert is entirely after target range → no shift
39 if (insertAt >= targetEnd)
40 return new AdjustedRange(targetStart, targetEnd);
41
42 // Insert is within target range → conflict
43 return null;
44 }
45
46 private static AdjustedRange? AdjustForModify(long modStart, long modEnd, long targetStart, long targetEnd)
47 {
48 // No overlap → no change
49 if (modEnd <= targetStart || modStart >= targetEnd)
50 return new AdjustedRange(targetStart, targetEnd);
51
52 // Any overlap with modify → conflict
53 return null;
54 }
55
56 private static AdjustedRange? AdjustForDelete(long delStart, long delEnd, long targetStart, long targetEnd)
57 {
58 var delLength = delEnd - delStart;
59
60 if (delLength == 0)
61 return new AdjustedRange(targetStart, targetEnd);
62
63 // Delete is entirely after target range → no shift
64 if (delStart >= targetEnd)
65 return new AdjustedRange(targetStart, targetEnd);
66
67 // Delete is entirely before target range → shift left
68 if (delEnd <= targetStart)
69 return new AdjustedRange(targetStart - delLength, targetEnd - delLength);
70
71 // Delete overlaps target range → conflict
72 return null;
73 }
74}
75
+ src/Catena.Shared/Overlap/OverlapDetector.cs
1using Catena.Shared.Models;
2
3namespace Catena.Shared.Overlap;
4
5/// <summary>
6/// Detects overlaps between two patches.
7/// Follows the decision tree from the PRD:
8/// Same file? → No → auto-apply
9/// Same file? → Yes → Same range (after offset)? → No → auto-apply
10/// Same range? → Yes → Same change? → Yes → deduplicate
11/// Same change? → No → CONFLICT
12/// </summary>
13public static class OverlapDetector
14{
15 public static PatchOverlapReport Detect(Patch patchA, Patch patchB)
16 {
17 var results = new List<OverlapResult>();
18
19 for (int a = 0; a < patchA.Ops.Count; a++)
20 {
21 for (int b = 0; b < patchB.Ops.Count; b++)
22 {
23 var opA = patchA.Ops[a];
24 var opB = patchB.Ops[b];
25
26 var result = DetectOpOverlap(opA, a, opB, b);
27 results.Add(result);
28 }
29 }
30
31 return new PatchOverlapReport
32 {
33 PatchIdA = patchA.Id,
34 PatchIdB = patchB.Id,
35 Results = results
36 };
37 }
38
39 private static OverlapResult DetectOpOverlap(Operation opA, int indexA, Operation opB, int indexB)
40 {
41 var fileA = NormalizePath(opA);
42 var fileB = NormalizePath(opB);
43
44 // Different files → auto-apply, commutative
45 if (!string.Equals(fileA, fileB, StringComparison.Ordinal))
46 {
47 return new OverlapResult
48 {
49 Kind = OverlapKind.AutoApply,
50 FileA = opA.File,
51 FileB = opB.File,
52 OpIndexA = indexA,
53 OpIndexB = indexB,
54 Reason = "Different files"
55 };
56 }
57
58 // Same file — check for special cases first
59 // Both are deletes of the same file → deduplicate
60 if (opA.Type == OpType.Delete && opB.Type == OpType.Delete)
61 {
62 return new OverlapResult
63 {
64 Kind = OverlapKind.Deduplicated,
65 FileA = opA.File,
66 FileB = opB.File,
67 OpIndexA = indexA,
68 OpIndexB = indexB,
69 Reason = "Both delete same file"
70 };
71 }
72
73 // One is a rename/move → conflict if the other touches the same file
74 if (opA.Type is OpType.Rename or OpType.Move || opB.Type is OpType.Rename or OpType.Move)
75 {
76 return new OverlapResult
77 {
78 Kind = OverlapKind.Conflict,
79 FileA = opA.File,
80 FileB = opB.File,
81 OpIndexA = indexA,
82 OpIndexB = indexB,
83 Reason = "Rename/move conflicts with other operation on same file"
84 };
85 }
86
87 // One is a delete, other is insert/modify → conflict
88 if (opA.Type == OpType.Delete || opB.Type == OpType.Delete)
89 {
90 return new OverlapResult
91 {
92 Kind = OverlapKind.Conflict,
93 FileA = opA.File,
94 FileB = opB.File,
95 OpIndexA = indexA,
96 OpIndexB = indexB,
97 Reason = "Delete conflicts with modification on same file"
98 };
99 }
100
101 // Both are file-level inserts (whole new file) → compare content
102 if (opA.Type == OpType.Insert && opB.Type == OpType.Insert
103 && opA.StartByte == 0 && opB.StartByte == 0)
104 {
105 if (AreContentsEqual(opA, opB))
106 {
107 return new OverlapResult
108 {
109 Kind = OverlapKind.Deduplicated,
110 FileA = opA.File,
111 FileB = opB.File,
112 OpIndexA = indexA,
113 OpIndexB = indexB,
114 Reason = "Identical insert"
115 };
116 }
117
118 return new OverlapResult
119 {
120 Kind = OverlapKind.Conflict,
121 FileA = opA.File,
122 FileB = opB.File,
123 OpIndexA = indexA,
124 OpIndexB = indexB,
125 Reason = "Both insert new file with different content"
126 };
127 }
128
129 // Same file, byte-level operations — check range overlap
130 return CheckRangeOverlap(opA, indexA, opB, indexB);
131 }
132
133 private static OverlapResult CheckRangeOverlap(Operation opA, int indexA, Operation opB, int indexB)
134 {
135 // No overlap in ranges → auto-apply
136 if (opA.EndByte <= opB.StartByte || opB.EndByte <= opA.StartByte)
137 {
138 return new OverlapResult
139 {
140 Kind = OverlapKind.AutoApply,
141 FileA = opA.File,
142 FileB = opB.File,
143 OpIndexA = indexA,
144 OpIndexB = indexB,
145 Reason = "Same file, non-overlapping ranges"
146 };
147 }
148
149 // Overlapping ranges — same change?
150 if (opA.Type == opB.Type
151 && opA.StartByte == opB.StartByte
152 && opA.EndByte == opB.EndByte
153 && AreContentsEqual(opA, opB))
154 {
155 return new OverlapResult
156 {
157 Kind = OverlapKind.Deduplicated,
158 FileA = opA.File,
159 FileB = opB.File,
160 OpIndexA = indexA,
161 OpIndexB = indexB,
162 Reason = "Identical change at same range"
163 };
164 }
165
166 // Overlapping ranges, different changes → conflict
167 return new OverlapResult
168 {
169 Kind = OverlapKind.Conflict,
170 FileA = opA.File,
171 FileB = opB.File,
172 OpIndexA = indexA,
173 OpIndexB = indexB,
174 Reason = "Overlapping ranges with different changes"
175 };
176 }
177
178 private static bool AreContentsEqual(Operation a, Operation b)
179 {
180 // If both have content hashes (server-side, blobs stored separately), compare hashes
181 if (a.ContentHash is not null && b.ContentHash is not null)
182 return string.Equals(a.ContentHash, b.ContentHash, StringComparison.Ordinal);
183
184 // If both have inline content, compare bytes
185 if (a.Content.Length > 0 || b.Content.Length > 0)
186 return a.Content.AsSpan().SequenceEqual(b.Content.AsSpan())
187 && a.EndByte == b.EndByte;
188
189 // Both empty content and no hashes — compare by size
190 return a.EndByte == b.EndByte;
191 }
192
193 private static string NormalizePath(Operation op) => op.File.Replace('\\', '/');
194}
195
+ src/Catena.Shared/Overlap/OverlapResult.cs
1using Catena.Shared.Models;
2
3namespace Catena.Shared.Overlap;
4
5public enum OverlapKind
6{
7 AutoApply,
8 Deduplicated,
9 Conflict
10}
11
12public sealed record OverlapResult
13{
14 public required OverlapKind Kind { get; init; }
15 public required string FileA { get; init; }
16 public required string FileB { get; init; }
17 public required int OpIndexA { get; init; }
18 public required int OpIndexB { get; init; }
19 public string? Reason { get; init; }
20}
21
22public sealed record PatchOverlapReport
23{
24 public required string PatchIdA { get; init; }
25 public required string PatchIdB { get; init; }
26 public required List<OverlapResult> Results { get; init; }
27
28 public bool HasConflicts => Results.Any(r => r.Kind == OverlapKind.Conflict);
29 public IEnumerable<OverlapResult> Conflicts => Results.Where(r => r.Kind == OverlapKind.Conflict);
30 public IEnumerable<OverlapResult> AutoApplies => Results.Where(r => r.Kind == OverlapKind.AutoApply);
31 public IEnumerable<OverlapResult> Deduplications => Results.Where(r => r.Kind == OverlapKind.Deduplicated);
32}
33
+ src/Catena.Shared/Tracking/IgnoreFilter.cs
1using System.Text.RegularExpressions;
2
3namespace Catena.Shared.Tracking;
4
5public sealed class IgnoreFilter
6{
7 private readonly List<Regex> _patterns = [];
8
9 public static IgnoreFilter Default => new(
10 [
11 ".catena/",
12 "bin/",
13 "obj/",
14 ".vs/",
15 ".idea/",
16 "node_modules/"
17 ]);
18
19 public IgnoreFilter(IEnumerable<string> patterns)
20 {
21 foreach (var pattern in patterns)
22 {
23 var trimmed = pattern.Trim();
24 if (trimmed is "" or ['#', ..]) continue;
25 _patterns.Add(GlobToRegex(trimmed));
26 }
27 }
28
29 public static async Task<IgnoreFilter> LoadAsync(string workspacePath)
30 {
31 var ignoreFile = Path.Combine(workspacePath, ".catena", "ignore");
32 var patterns = new List<string>();
33
34 // Always ignore .catena/ itself
35 patterns.Add(".catena/");
36
37 if (File.Exists(ignoreFile))
38 {
39 var lines = await File.ReadAllLinesAsync(ignoreFile);
40 patterns.AddRange(lines);
41 }
42
43 return new IgnoreFilter(patterns);
44 }
45
46 public bool IsIgnored(string relativePath)
47 {
48 // Normalize to forward slashes
49 var normalized = relativePath.Replace('\\', '/');
50 return _patterns.Any(p => p.IsMatch(normalized));
51 }
52
53 private static Regex GlobToRegex(string glob)
54 {
55 var normalized = glob.Replace('\\', '/');
56
57 // Directory pattern: "bin/" matches "bin/" and anything inside
58 if (normalized.EndsWith('/'))
59 {
60 var escaped = Regex.Escape(normalized.TrimEnd('/'));
61 return new Regex($"(^|/){escaped}(/|$)", RegexOptions.Compiled);
62 }
63
64 // File pattern: convert glob wildcards
65 var pattern = Regex.Escape(normalized)
66 .Replace(@"\*\*", "§DOUBLESTAR§")
67 .Replace(@"\*", "[^/]*")
68 .Replace(@"\?", "[^/]")
69 .Replace("§DOUBLESTAR§", ".*");
70
71 return new Regex($"(^|/){pattern}$", RegexOptions.Compiled);
72 }
73}
74
+ src/Catena.Shared/Tracking/WorkspaceDiff.cs
1namespace Catena.Shared.Tracking;
2
3public sealed record WorkspaceDiff
4{
5 public required List<string> Added { get; init; }
6 public required List<string> Modified { get; init; }
7 public required List<string> Deleted { get; init; }
8
9 public bool HasChanges => Added.Count > 0 || Modified.Count > 0 || Deleted.Count > 0;
10
11 public static WorkspaceDiff Compute(
12 Dictionary<string, string> localFiles,
13 Dictionary<string, string> trunkFiles)
14 {
15 var added = new List<string>();
16 var modified = new List<string>();
17 var deleted = new List<string>();
18
19 foreach (var (path, hash) in localFiles)
20 {
21 if (!trunkFiles.TryGetValue(path, out var trunkHash))
22 added.Add(path);
23 else if (hash != trunkHash)
24 modified.Add(path);
25 }
26
27 foreach (var path in trunkFiles.Keys)
28 {
29 if (!localFiles.ContainsKey(path))
30 deleted.Add(path);
31 }
32
33 added.Sort(StringComparer.Ordinal);
34 modified.Sort(StringComparer.Ordinal);
35 deleted.Sort(StringComparer.Ordinal);
36
37 return new WorkspaceDiff
38 {
39 Added = added,
40 Modified = modified,
41 Deleted = deleted
42 };
43 }
44}
45
+ src/Catena.Shared/Tracking/WorkspaceScanner.cs
1using System.Security.Cryptography;
2
3namespace Catena.Shared.Tracking;
4
5public static class WorkspaceScanner
6{
7 public static async Task<Dictionary<string, string>> ScanAsync(string workspacePath, IgnoreFilter filter)
8 {
9 var result = new Dictionary<string, string>();
10 await ScanDirectory(workspacePath, workspacePath, filter, result);
11 return result;
12 }
13
14 private static async Task ScanDirectory(
15 string rootPath, string currentPath, IgnoreFilter filter, Dictionary<string, string> result)
16 {
17 foreach (var file in Directory.GetFiles(currentPath))
18 {
19 var relativePath = Path.GetRelativePath(rootPath, file).Replace('\\', '/');
20 if (filter.IsIgnored(relativePath)) continue;
21
22 var hash = await HashFileAsync(file);
23 result[relativePath] = hash;
24 }
25
26 foreach (var dir in Directory.GetDirectories(currentPath))
27 {
28 var relativePath = Path.GetRelativePath(rootPath, dir).Replace('\\', '/') + "/";
29 if (filter.IsIgnored(relativePath)) continue;
30
31 await ScanDirectory(rootPath, dir, filter, result);
32 }
33 }
34
35 public static async Task<string> HashFileAsync(string filePath)
36 {
37 await using var stream = File.OpenRead(filePath);
38 var hashBytes = await SHA256.HashDataAsync(stream);
39 return Convert.ToHexStringLower(hashBytes);
40 }
41}
42
+ src/Catena.Storage/Catena.Storage.csproj
1<Project Sdk="Microsoft.NET.Sdk">
2
3 <ItemGroup>
4 <ProjectReference Include="..\Catena.Shared\Catena.Shared.csproj" />
5 </ItemGroup>
6
7 <PropertyGroup>
8 <TargetFramework>net10.0</TargetFramework>
9 <ImplicitUsings>enable</ImplicitUsings>
10 <Nullable>enable</Nullable>
11 </PropertyGroup>
12
13</Project>
14
+ src/Catena.Storage/CatenaEvents.cs
1namespace Catena.Storage;
2
3public sealed class CatenaEvents
4{
5 public event Action<string>? PatchChanged;
6 public event Action<string>? TrunkChanged;
7 public event Action<string>? ReleaseChanged;
8 public event Action<string>? ReviewChanged;
9 public event Action? UserChanged;
10
11 public void NotifyPatchChanged(string projectId) => PatchChanged?.Invoke(projectId);
12 public void NotifyTrunkChanged(string projectId) => TrunkChanged?.Invoke(projectId);
13 public void NotifyReleaseChanged(string projectId) => ReleaseChanged?.Invoke(projectId);
14 public void NotifyReviewChanged(string projectId) => ReviewChanged?.Invoke(projectId);
15 public void NotifyUserChanged() => UserChanged?.Invoke();
16}
17
+ src/Catena.Storage/PatchStore.cs
1using System.Security.Cryptography;
2using System.Text;
3using System.Text.Json;
4using Catena.Shared.Dtos;
5using Catena.Shared.Models;
6
7namespace Catena.Storage;
8
9public sealed class PatchStore
10{
11 private readonly string _baseDir;
12 private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
13
14 public PatchStore(string baseDir)
15 {
16 _baseDir = baseDir;
17 }
18
19 private string ProjectPatchDir(string projectId) => Path.Combine(_baseDir, projectId, "patches");
20 private string PatchDir(string projectId, string patchId) => Path.Combine(ProjectPatchDir(projectId), patchId);
21 private string PatchMetaFile(string projectId, string patchId) => Path.Combine(PatchDir(projectId, patchId), "patch.json");
22 private string PatchBlobDir(string projectId, string patchId) => Path.Combine(PatchDir(projectId, patchId), "blobs");
23
24 public async Task<Patch> CreateAsync(string projectId, CreatePatchRequest request)
25 {
26 var ops = new List<Operation>();
27 var blobMap = new Dictionary<int, byte[]>();
28
29 for (int i = 0; i < request.Ops.Count; i++)
30 {
31 var fileOp = request.Ops[i];
32 byte[] content = fileOp.ContentBase64 is not null
33 ? Convert.FromBase64String(fileOp.ContentBase64)
34 : [];
35
36 var lineCount = content.Length > 0 ? CountLines(content) : 0;
37
38 ops.Add(new Operation
39 {
40 Type = fileOp.Type,
41 File = fileOp.File,
42 StartByte = 0,
43 EndByte = fileOp.Type == OpType.Delete ? 0 : content.Length,
44 Content = [], // stored as blob, not inline
45 ContentHash = content.Length > 0 ? Convert.ToHexStringLower(SHA256.HashData(content)) : null,
46 LinesAdded = fileOp.Type is OpType.Insert or OpType.Modify ? lineCount : 0,
47 LinesDeleted = fileOp.Type is OpType.Delete or OpType.Modify ? lineCount : 0,
48 NewPath = fileOp.NewPath
49 });
50
51 if (content.Length > 0)
52 blobMap[i] = content;
53 }
54
55 var patchId = ComputePatchId(projectId, request, ops);
56
57 var patch = new Patch
58 {
59 Id = patchId,
60 Author = request.Author,
61 ProjectId = projectId,
62 Dependencies = request.Dependencies,
63 Targets = request.Targets,
64 Maturity = request.Maturity,
65 Ops = ops,
66 BaseHash = request.BaseHash,
67 Description = request.Description
68 };
69
70 var patchDir = PatchDir(projectId, patchId);
71 var blobDir = PatchBlobDir(projectId, patchId);
72 Directory.CreateDirectory(blobDir);
73
74 await File.WriteAllTextAsync(PatchMetaFile(projectId, patchId), JsonSerializer.Serialize(patch, JsonOptions));
75
76 foreach (var (index, blob) in blobMap)
77 {
78 await File.WriteAllBytesAsync(Path.Combine(blobDir, $"{index}.bin"), blob);
79 }
80
81 return patch;
82 }
83
84 public async Task<Patch?> GetAsync(string projectId, string patchId)
85 {
86 var metaFile = PatchMetaFile(projectId, patchId);
87 if (!File.Exists(metaFile)) return null;
88 var json = await File.ReadAllTextAsync(metaFile);
89 return JsonSerializer.Deserialize<Patch>(json);
90 }
91
92 public async Task<byte[]?> GetBlobAsync(string projectId, string patchId, int opIndex)
93 {
94 var blobFile = Path.Combine(PatchBlobDir(projectId, patchId), $"{opIndex}.bin");
95 if (!File.Exists(blobFile)) return null;
96 return await File.ReadAllBytesAsync(blobFile);
97 }
98
99 public async Task<List<Patch>> ListAsync(string projectId, Maturity? maturityFilter = null, string? authorFilter = null)
100 {
101 var patches = new List<Patch>();
102 var patchesDir = ProjectPatchDir(projectId);
103 if (!Directory.Exists(patchesDir)) return patches;
104
105 foreach (var dir in Directory.GetDirectories(patchesDir))
106 {
107 var metaFile = Path.Combine(dir, "patch.json");
108 if (!File.Exists(metaFile)) continue;
109
110 var json = await File.ReadAllTextAsync(metaFile);
111 var patch = JsonSerializer.Deserialize<Patch>(json);
112 if (patch is null) continue;
113
114 if (maturityFilter.HasValue && patch.Maturity != maturityFilter.Value) continue;
115 if (authorFilter is not null && patch.Author != authorFilter) continue;
116
117 patches.Add(patch);
118 }
119
120 return patches.OrderByDescending(p => p.Timestamp).ToList();
121 }
122
123 private static readonly HashSet<(Maturity From, Maturity To)> AllowedTransitions =
124 [
125 (Maturity.DraftLocal, Maturity.DraftSynced),
126 (Maturity.DraftSynced, Maturity.DraftShared),
127 (Maturity.DraftShared, Maturity.Proposed),
128 (Maturity.DraftSynced, Maturity.Proposed),
129 (Maturity.Proposed, Maturity.Accepted),
130 (Maturity.Proposed, Maturity.DraftSynced) // withdraw: back to draft to continue working
131 ];
132
133 public async Task<(Patch? Patch, string? Error)> UpdateMaturityAsync(string projectId, string patchId, Maturity newMaturity)
134 {
135 var patch = await GetAsync(projectId, patchId);
136 if (patch is null) return (null, "Patch not found.");
137
138 if (patch.Maturity == newMaturity) return (patch, null);
139
140 if (!AllowedTransitions.Contains((patch.Maturity, newMaturity)))
141 return (patch, $"Invalid transition: {patch.Maturity} → {newMaturity}");
142
143 // Dependency check: all dependencies must be Accepted before this patch can be Accepted
144 if (newMaturity == Maturity.Accepted && patch.Dependencies.Count > 0)
145 {
146 foreach (var depId in patch.Dependencies)
147 {
148 var dep = await GetAsync(projectId, depId);
149 if (dep is null)
150 return (patch, $"Dependency {depId} not found.");
151 if (dep.Maturity != Maturity.Accepted)
152 return (patch, $"Dependency '{dep.Description}' ({depId}) must be accepted first.");
153 }
154 }
155
156 var updated = patch with
157 {
158 Maturity = newMaturity,
159 ProposedAt = newMaturity == Maturity.Proposed ? DateTime.UtcNow : patch.ProposedAt,
160 AcceptedAt = newMaturity == Maturity.Accepted ? DateTime.UtcNow : patch.AcceptedAt
161 };
162 await File.WriteAllTextAsync(PatchMetaFile(projectId, patchId), JsonSerializer.Serialize(updated, JsonOptions));
163 return (updated, null);
164 }
165
166 private static string ComputePatchId(string projectId, CreatePatchRequest request, List<Operation> ops)
167 {
168 var sb = new StringBuilder();
169 sb.Append(projectId);
170 sb.Append(request.Author);
171 sb.Append(request.Description);
172 foreach (var op in ops)
173 {
174 sb.Append(op.Type);
175 sb.Append(op.File);
176 }
177 sb.Append(DateTime.UtcNow.Ticks);
178
179 var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
180 return Convert.ToHexStringLower(hashBytes)[..16];
181 }
182
183 private static int CountLines(byte[] content)
184 {
185 if (content.Length == 0) return 0;
186 var count = 1;
187 foreach (var b in content)
188 {
189 if (b == (byte)'\n') count++;
190 }
191 return count;
192 }
193}
194
+ src/Catena.Storage/ProjectStore.cs
1using System.Text.Json;
2using Catena.Shared.Models;
3
4namespace Catena.Storage;
5
6public sealed class ProjectStore
7{
8 private readonly string _baseDir;
9 private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
10
11 public ProjectStore(string baseDir)
12 {
13 _baseDir = baseDir;
14 Directory.CreateDirectory(_baseDir);
15 }
16
17 private string ProjectDir(string projectId) => Path.Combine(_baseDir, projectId);
18 private string MetaFile(string projectId) => Path.Combine(ProjectDir(projectId), "project.json");
19
20 public async Task<Project> CreateAsync(string name, bool isPublic = false)
21 {
22 var project = new Project
23 {
24 Id = Guid.NewGuid().ToString("N")[..12],
25 Name = name,
26 IsPublic = isPublic
27 };
28
29 var dir = ProjectDir(project.Id);
30 Directory.CreateDirectory(dir);
31 Directory.CreateDirectory(Path.Combine(dir, "patches"));
32 Directory.CreateDirectory(Path.Combine(dir, "snapshots"));
33
34 await File.WriteAllTextAsync(MetaFile(project.Id), JsonSerializer.Serialize(project, JsonOptions));
35 return project;
36 }
37
38 public async Task<Project?> GetAsync(string projectId)
39 {
40 var path = MetaFile(projectId);
41 if (!File.Exists(path)) return null;
42 var json = await File.ReadAllTextAsync(path);
43 return JsonSerializer.Deserialize<Project>(json);
44 }
45
46 public async Task<List<Project>> ListAsync()
47 {
48 var projects = new List<Project>();
49 if (!Directory.Exists(_baseDir)) return projects;
50
51 foreach (var dir in Directory.GetDirectories(_baseDir))
52 {
53 var metaFile = Path.Combine(dir, "project.json");
54 if (!File.Exists(metaFile)) continue;
55 var json = await File.ReadAllTextAsync(metaFile);
56 var project = JsonSerializer.Deserialize<Project>(json);
57 if (project is not null) projects.Add(project);
58 }
59
60 return projects;
61 }
62}
63
+ src/Catena.Storage/ReleaseStore.cs
1using System.Text.Json;
2using Catena.Shared.Models;
3
4namespace Catena.Storage;
5
6public sealed class ReleaseStore
7{
8 private readonly string _baseDir;
9 private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
10
11 public ReleaseStore(string baseDir)
12 {
13 _baseDir = baseDir;
14 }
15
16 private string ReleasesDir(string projectId) => Path.Combine(_baseDir, projectId, "releases");
17 private string ReleaseFile(string projectId, string version) => Path.Combine(ReleasesDir(projectId), $"{version}.json");
18
19 public async Task<Release> CreateAsync(string projectId, string version, Dictionary<string, string> trunkFiles,
20 string trunkHash, List<string>? patchIds = null, string? basePatchId = null)
21 {
22 var release = new Release
23 {
24 Version = version,
25 ProjectId = projectId,
26 CreatedAt = DateTime.UtcNow,
27 Files = new Dictionary<string, string>(trunkFiles),
28 TrunkHash = trunkHash,
29 PatchIds = patchIds ?? [],
30 BasePatchId = basePatchId
31 };
32
33 var dir = ReleasesDir(projectId);
34 Directory.CreateDirectory(dir);
35 await File.WriteAllTextAsync(ReleaseFile(projectId, version), JsonSerializer.Serialize(release, JsonOptions));
36 return release;
37 }
38
39 public async Task<Release?> GetAsync(string projectId, string version)
40 {
41 var file = ReleaseFile(projectId, version);
42 if (!File.Exists(file)) return null;
43 var json = await File.ReadAllTextAsync(file);
44 return JsonSerializer.Deserialize<Release>(json);
45 }
46
47 public async Task<List<Release>> ListAsync(string projectId)
48 {
49 var releases = new List<Release>();
50 var dir = ReleasesDir(projectId);
51 if (!Directory.Exists(dir)) return releases;
52
53 foreach (var file in Directory.GetFiles(dir, "*.json"))
54 {
55 var json = await File.ReadAllTextAsync(file);
56 var release = JsonSerializer.Deserialize<Release>(json);
57 if (release is not null) releases.Add(release);
58 }
59
60 return releases.OrderByDescending(r => r.CreatedAt).ToList();
61 }
62
63 public bool Exists(string projectId, string version) => File.Exists(ReleaseFile(projectId, version));
64
65 public async Task<Release?> ApplyPatchAsync(string projectId, string version, Patch patch, PatchStore patchStore)
66 {
67 var release = await GetAsync(projectId, version);
68 if (release is null) return null;
69
70 var files = new Dictionary<string, string>(release.Files);
71 for (int i = 0; i < patch.Ops.Count; i++)
72 {
73 var op = patch.Ops[i];
74 switch (op.Type)
75 {
76 case OpType.Insert:
77 case OpType.Modify:
78 var blob = await patchStore.GetBlobAsync(projectId, patch.Id, i);
79 if (blob is not null)
80 {
81 var hash = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(blob));
82 files[op.File] = hash;
83 }
84 break;
85 case OpType.Delete:
86 files.Remove(op.File);
87 break;
88 case OpType.Rename:
89 case OpType.Move:
90 if (op.NewPath is not null && files.Remove(op.File, out var h))
91 files[op.NewPath] = h;
92 break;
93 }
94 }
95
96 var updated = release with
97 {
98 Files = files,
99 TrunkHash = TrunkState.ComputeHash(files)
100 };
101 await File.WriteAllTextAsync(ReleaseFile(projectId, version), JsonSerializer.Serialize(updated, JsonOptions));
102 return updated;
103 }
104}
105
+ src/Catena.Storage/ReviewStore.cs
1using System.Text.Json;
2using Catena.Shared.Models;
3
4namespace Catena.Storage;
5
6public sealed class ReviewStore
7{
8 private readonly string _baseDir;
9 private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
10
11 public ReviewStore(string baseDir)
12 {
13 _baseDir = baseDir;
14 }
15
16 private string ReviewsDir(string projectId, string patchId) =>
17 Path.Combine(_baseDir, projectId, "patches", patchId, "reviews");
18
19 private string ReviewFile(string projectId, string patchId, string reviewId) =>
20 Path.Combine(ReviewsDir(projectId, patchId), $"{reviewId}.json");
21
22 public async Task<Review> CreateAsync(string projectId, string patchId, string reviewerId, string reviewerName,
23 string? filePath = null, int? lineNumber = null, string? comment = null)
24 {
25 var review = new Review
26 {
27 Id = Guid.NewGuid().ToString("N")[..12],
28 PatchId = patchId,
29 ProjectId = projectId,
30 ReviewerId = reviewerId,
31 ReviewerName = reviewerName,
32 FilePath = filePath,
33 LineNumber = lineNumber,
34 Comment = comment ?? ""
35 };
36
37 var dir = ReviewsDir(projectId, patchId);
38 Directory.CreateDirectory(dir);
39 await File.WriteAllTextAsync(ReviewFile(projectId, patchId, review.Id), JsonSerializer.Serialize(review, JsonOptions));
40 return review;
41 }
42
43 public async Task<Review?> UpdateAsync(string projectId, string patchId, string reviewId, ReviewStatus status, string comment)
44 {
45 var file = ReviewFile(projectId, patchId, reviewId);
46 if (!File.Exists(file)) return null;
47
48 var json = await File.ReadAllTextAsync(file);
49 var review = JsonSerializer.Deserialize<Review>(json);
50 if (review is null) return null;
51
52 var updated = review with { Status = status, Comment = comment, UpdatedAt = DateTime.UtcNow };
53 await File.WriteAllTextAsync(file, JsonSerializer.Serialize(updated, JsonOptions));
54 return updated;
55 }
56
57 public async Task<List<Review>> ListForPatchAsync(string projectId, string patchId)
58 {
59 var dir = ReviewsDir(projectId, patchId);
60 if (!Directory.Exists(dir)) return [];
61
62 var reviews = new List<Review>();
63 foreach (var file in Directory.GetFiles(dir, "*.json"))
64 {
65 var json = await File.ReadAllTextAsync(file);
66 var review = JsonSerializer.Deserialize<Review>(json);
67 if (review is not null) reviews.Add(review);
68 }
69 return reviews.OrderBy(r => r.CreatedAt).ToList();
70 }
71
72}
73
+ src/Catena.Storage/TrunkState.cs
1using System.Security.Cryptography;
2using System.Text;
3using System.Text.Json;
4using Catena.Shared.Models;
5
6namespace Catena.Storage;
7
8public sealed record BlobRef(string PatchId, int OpIndex);
9
10public sealed class TrunkState
11{
12 private readonly string _baseDir;
13 private readonly SemaphoreSlim _lock = new(1, 1);
14 private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
15
16 public TrunkState(string baseDir)
17 {
18 _baseDir = baseDir;
19 }
20
21 private string TrunkFile(string projectId) =>
22 Path.Combine(_baseDir, projectId, "trunk.json");
23
24 private string BlobIndexFile(string projectId) =>
25 Path.Combine(_baseDir, projectId, "trunk-blobs.json");
26
27 public async Task<Dictionary<string, string>> GetFilesAsync(string projectId)
28 {
29 var file = TrunkFile(projectId);
30 if (!File.Exists(file)) return new();
31
32 var json = await File.ReadAllTextAsync(file);
33 return JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
34 }
35
36 public async Task SaveAsync(string projectId, Dictionary<string, string> files)
37 {
38 var dir = Path.Combine(_baseDir, projectId);
39 Directory.CreateDirectory(dir);
40 await File.WriteAllTextAsync(TrunkFile(projectId), JsonSerializer.Serialize(files, JsonOptions));
41 }
42
43 public async Task<Dictionary<string, BlobRef>> GetBlobIndexAsync(string projectId)
44 {
45 var file = BlobIndexFile(projectId);
46 if (!File.Exists(file)) return new();
47 var json = await File.ReadAllTextAsync(file);
48 return JsonSerializer.Deserialize<Dictionary<string, BlobRef>>(json) ?? new();
49 }
50
51 private async Task SaveBlobIndexAsync(string projectId, Dictionary<string, BlobRef> index)
52 {
53 var dir = Path.Combine(_baseDir, projectId);
54 Directory.CreateDirectory(dir);
55 await File.WriteAllTextAsync(BlobIndexFile(projectId), JsonSerializer.Serialize(index, JsonOptions));
56 }
57
58 public async Task ApplyPatchAsync(string projectId, Patch patch, PatchStore patchStore)
59 {
60 await _lock.WaitAsync();
61 try
62 {
63 var files = await GetFilesAsync(projectId);
64 var blobIndex = await GetBlobIndexAsync(projectId);
65
66 for (int i = 0; i < patch.Ops.Count; i++)
67 {
68 var op = patch.Ops[i];
69 switch (op.Type)
70 {
71 case OpType.Insert:
72 {
73 var blob = await patchStore.GetBlobAsync(projectId, patch.Id, i);
74 var hash = ComputeBlobHash(blob ?? []);
75 files[op.File] = hash;
76 blobIndex[op.File] = new BlobRef(patch.Id, i);
77 break;
78 }
79 case OpType.Modify:
80 {
81 var blob = await patchStore.GetBlobAsync(projectId, patch.Id, i);
82 var hash = ComputeBlobHash(blob ?? []);
83 files[op.File] = hash;
84 blobIndex[op.File] = new BlobRef(patch.Id, i);
85 break;
86 }
87 case OpType.Delete:
88 files.Remove(op.File);
89 blobIndex.Remove(op.File);
90 break;
91 case OpType.Rename:
92 case OpType.Move:
93 if (op.NewPath is not null && files.Remove(op.File, out var existingHash))
94 {
95 files[op.NewPath] = existingHash;
96 if (blobIndex.Remove(op.File, out var existingRef))
97 blobIndex[op.NewPath] = existingRef;
98 }
99 break;
100 }
101 }
102
103 await SaveAsync(projectId, files);
104 await SaveBlobIndexAsync(projectId, blobIndex);
105 }
106 finally
107 {
108 _lock.Release();
109 }
110 }
111
112 private static string ComputeBlobHash(byte[] data)
113 {
114 var hashBytes = SHA256.HashData(data);
115 return Convert.ToHexStringLower(hashBytes);
116 }
117
118 public static string ComputeHash(Dictionary<string, string> files)
119 {
120 var sb = new StringBuilder();
121 foreach (var (path, hash) in files.OrderBy(f => f.Key, StringComparer.Ordinal))
122 {
123 sb.Append(path);
124 sb.Append(':');
125 sb.Append(hash);
126 sb.Append('\n');
127 }
128
129 var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
130 return Convert.ToHexStringLower(hashBytes)[..16];
131 }
132}
133
+ src/Catena.Storage/UserStore.cs
1using System.Security.Cryptography;
2using System.Text.Json;
3using Catena.Shared.Models;
4
5namespace Catena.Storage;
6
7public sealed class UserStore
8{
9 private readonly string _baseDir;
10 private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
11
12 public UserStore(string baseDir)
13 {
14 _baseDir = baseDir;
15 Directory.CreateDirectory(_baseDir);
16 }
17
18 private string UserDir(string userId) => Path.Combine(_baseDir, userId);
19 private string UserFile(string userId) => Path.Combine(UserDir(userId), "user.json");
20
21 public async Task<(User User, string PlainKey)> CreateAsync(string name, UserRole role)
22 {
23 var id = Guid.NewGuid().ToString("N")[..12];
24 var plainKey = GenerateApiKey();
25 var keyHash = HashApiKey(plainKey);
26
27 var user = new User
28 {
29 Id = id,
30 Name = name,
31 Role = role,
32 ApiKeyHash = keyHash
33 };
34
35 Directory.CreateDirectory(UserDir(id));
36 await File.WriteAllTextAsync(UserFile(id), JsonSerializer.Serialize(user, JsonOptions));
37 return (user, plainKey);
38 }
39
40 public async Task<User?> GetAsync(string userId)
41 {
42 var file = UserFile(userId);
43 if (!File.Exists(file)) return null;
44 var json = await File.ReadAllTextAsync(file);
45 return JsonSerializer.Deserialize<User>(json);
46 }
47
48 public async Task<User?> UpdateRoleAsync(string userId, UserRole newRole)
49 {
50 var user = await GetAsync(userId);
51 if (user is null) return null;
52
53 var updated = user with { Role = newRole };
54 await File.WriteAllTextAsync(UserFile(userId), JsonSerializer.Serialize(updated, JsonOptions));
55 return updated;
56 }
57
58 public async Task<User?> GetByApiKeyAsync(string plainKey)
59 {
60 var keyHash = HashApiKey(plainKey);
61 if (!Directory.Exists(_baseDir)) return null;
62
63 foreach (var dir in Directory.GetDirectories(_baseDir))
64 {
65 var file = Path.Combine(dir, "user.json");
66 if (!File.Exists(file)) continue;
67 var json = await File.ReadAllTextAsync(file);
68 var user = JsonSerializer.Deserialize<User>(json);
69 if (user is not null && user.ApiKeyHash == keyHash)
70 return user;
71 }
72
73 return null;
74 }
75
76 public async Task<List<User>> ListAsync()
77 {
78 var users = new List<User>();
79 if (!Directory.Exists(_baseDir)) return users;
80
81 foreach (var dir in Directory.GetDirectories(_baseDir))
82 {
83 var file = Path.Combine(dir, "user.json");
84 if (!File.Exists(file)) continue;
85 var json = await File.ReadAllTextAsync(file);
86 var user = JsonSerializer.Deserialize<User>(json);
87 if (user is not null) users.Add(user);
88 }
89
90 return users;
91 }
92
93 public bool DeleteUser(string userId)
94 {
95 var dir = UserDir(userId);
96 if (!Directory.Exists(dir)) return false;
97 Directory.Delete(dir, true);
98 return true;
99 }
100
101 public bool HasAnyUsers()
102 {
103 if (!Directory.Exists(_baseDir)) return false;
104 return Directory.GetDirectories(_baseDir).Length > 0;
105 }
106
107 private static string GenerateApiKey()
108 {
109 var bytes = RandomNumberGenerator.GetBytes(32);
110 return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
111 }
112
113 public static string HashApiKey(string plainKey)
114 {
115 var bytes = System.Text.Encoding.UTF8.GetBytes(plainKey);
116 var hash = SHA256.HashData(bytes);
117 return Convert.ToHexStringLower(hash);
118 }
119}
120
+ src/Catena.Storage/WebhookStore.cs
1using System.Text.Json;
2using Catena.Shared.Models;
3
4namespace Catena.Storage;
5
6public sealed class WebhookStore
7{
8 private readonly string _baseDir;
9 private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
10
11 public WebhookStore(string baseDir)
12 {
13 _baseDir = baseDir;
14 }
15
16 private string WebhooksDir(string projectId) => Path.Combine(_baseDir, projectId, "webhooks");
17 private string WebhookFile(string projectId, string id) => Path.Combine(WebhooksDir(projectId), $"{id}.json");
18
19 public async Task<Webhook> CreateAsync(string projectId, string eventName, string url)
20 {
21 var webhook = new Webhook
22 {
23 Id = Guid.NewGuid().ToString("N")[..12],
24 ProjectId = projectId,
25 Event = eventName,
26 Url = url
27 };
28
29 var dir = WebhooksDir(projectId);
30 Directory.CreateDirectory(dir);
31 await File.WriteAllTextAsync(WebhookFile(projectId, webhook.Id), JsonSerializer.Serialize(webhook, JsonOptions));
32 return webhook;
33 }
34
35 public async Task<List<Webhook>> ListAsync(string projectId)
36 {
37 var dir = WebhooksDir(projectId);
38 if (!Directory.Exists(dir)) return [];
39
40 var hooks = new List<Webhook>();
41 foreach (var file in Directory.GetFiles(dir, "*.json"))
42 {
43 var json = await File.ReadAllTextAsync(file);
44 var hook = JsonSerializer.Deserialize<Webhook>(json);
45 if (hook is not null) hooks.Add(hook);
46 }
47 return hooks.OrderBy(h => h.Event).ToList();
48 }
49
50 public bool Delete(string projectId, string id)
51 {
52 var file = WebhookFile(projectId, id);
53 if (!File.Exists(file)) return false;
54 File.Delete(file);
55 return true;
56 }
57}
58
+ src/Catena.Web/Areas/MyFeature/Pages/Page1.cshtml
1@page
2@model Catena.Web.MyFeature.Pages.Page1Model
3
4<!DOCTYPE html>
5
6<html>
7<head>
8 <meta name="viewport" content="width=device-width" />
9 <title>Page1</title>
10</head>
11<body>
12</body>
13</html>
14
+ src/Catena.Web/Areas/MyFeature/Pages/Page1.cshtml.cs
1using Microsoft.AspNetCore.Mvc;
2using Microsoft.AspNetCore.Mvc.RazorPages;
3
4namespace Catena.Web.MyFeature.Pages;
5
6public class Page1Model : PageModel
7{
8 public void OnGet()
9 {
10
11 }
12}
13
+ src/Catena.Web/Catena.Web.csproj
1<Project Sdk="Microsoft.NET.Sdk.Web">
2
3 <PropertyGroup>
4 <TargetFramework>net10.0</TargetFramework>
5 <Nullable>enable</Nullable>
6 <ImplicitUsings>enable</ImplicitUsings>
7 <OutputType>Library</OutputType>
8 <NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
9 </PropertyGroup>
10
11 <ItemGroup>
12 <ProjectReference Include="..\Catena.Shared\Catena.Shared.csproj" />
13 <ProjectReference Include="..\Catena.Storage\Catena.Storage.csproj" />
14 </ItemGroup>
15
16</Project>
17
+ src/Catena.Web/Components/App.razor
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <base href="/" />
7 <title>Catena</title>
8 <HeadOutlet @rendermode="RenderMode.InteractiveServer" />
9 <link rel="stylesheet" href="_content/Catena.Web/css/app.css" />
10</head>
11<body>
12 <Routes @rendermode="RenderMode.InteractiveServer" />
13 <script src="_framework/blazor.web.js"></script>
14</body>
15</html>
16
+ src/Catena.Web/Components/Layout/MainLayout.razor
1@inherits LayoutComponentBase
2
3<div class="app">
4 <nav class="sidebar">
5 <div class="sidebar-header">
6 <h2>Catena</h2>
7 </div>
8 <NavMenu />
9 </nav>
10 <main>
11 <header class="top-bar">
12 <AuthorizeView>
13 <Authorized>
14 <span>@context.User.Identity?.Name</span>
15 <form method="post" action="/auth/logout">
16 <button type="submit" class="btn-link">Logout</button>
17 </form>
18 </Authorized>
19 <NotAuthorized>
20 <a href="/login">Login</a>
21 </NotAuthorized>
22 </AuthorizeView>
23 </header>
24 <div class="content">
25 @Body
26 </div>
27 </main>
28</div>
29
+ src/Catena.Web/Components/Layout/NavMenu.razor
1<ul class="nav-menu">
2 <li><NavLink href="/app" Match="NavLinkMatch.All">Projects</NavLink></li>
3</ul>
4
5<AuthorizeView Roles="Admin">
6 <Authorized>
7 <hr />
8 <ul class="nav-menu">
9 <li><NavLink href="/app/admin/users">Users</NavLink></li>
10 </ul>
11 </Authorized>
12</AuthorizeView>
13
+ src/Catena.Web/Components/Pages/History.razor
1@page "/app/projects/{ProjectId}/history"
2@inject PatchStore PatchStore
3
4<h1>History</h1>
5
6<div class="filters">
7 <input type="text" @bind="_fileFilter" placeholder="Filter by file..." />
8 <input type="text" @bind="_authorFilter" placeholder="Filter by author..." />
9 <button class="btn" @onclick="LoadHistory">Filter</button>
10</div>
11
12@if (_patches is null)
13{
14 <p>Loading...</p>
15}
16else if (_patches.Count == 0)
17{
18 <p>No patches in history.</p>
19}
20else
21{
22 <table class="data-table">
23 <thead>
24 <tr><th>ID</th><th>Author</th><th>Description</th><th>Ops</th><th>Time</th></tr>
25 </thead>
26 <tbody>
27 @foreach (var p in _patches)
28 {
29 <tr>
30 <td><a href="/app/projects/@ProjectId/patches/@p.Id"><code>@p.Id[..8]</code></a></td>
31 <td>@p.Author</td>
32 <td>@p.Description</td>
33 <td>@p.Ops.Count</td>
34 <td>@p.Timestamp.ToString("yyyy-MM-dd HH:mm")</td>
35 </tr>
36 }
37 </tbody>
38 </table>
39}
40
41@code {
42 [Parameter] public string ProjectId { get; set; } = "";
43
44 private List<Patch>? _patches;
45 private string _fileFilter = "";
46 private string _authorFilter = "";
47
48 protected override async Task OnInitializedAsync()
49 {
50 await LoadHistory();
51 }
52
53 private async Task LoadHistory()
54 {
55 var all = await PatchStore.ListAsync(ProjectId, maturityFilter: Maturity.Accepted,
56 authorFilter: string.IsNullOrWhiteSpace(_authorFilter) ? null : _authorFilter);
57
58 if (!string.IsNullOrWhiteSpace(_fileFilter))
59 {
60 all = all.Where(p => p.Ops.Any(op => op.File.Contains(_fileFilter, StringComparison.OrdinalIgnoreCase))).ToList();
61 }
62
63 _patches = all;
64 }
65}
66
+ src/Catena.Web/Components/Pages/Login.razor
1@page "/login"
2@using Microsoft.AspNetCore.Authentication
3@using Microsoft.AspNetCore.Authentication.Cookies
4@using System.Security.Claims
5@inject NavigationManager Nav
6@inject UserStore UserStore
7@inject IHttpContextAccessor HttpContextAccessor
8
9<h1>Login</h1>
10
11@if (_error is not null)
12{
13 <div class="error">@_error</div>
14}
15
16<form @onsubmit="HandleLogin">
17 <label>API Key</label>
18 <input type="password" @bind="_apiKey" placeholder="Enter your API key" />
19 <button type="submit">Login</button>
20</form>
21
22@code {
23 private string _apiKey = "";
24 private string? _error;
25
26 private async Task HandleLogin()
27 {
28 if (string.IsNullOrWhiteSpace(_apiKey))
29 {
30 _error = "API key is required.";
31 return;
32 }
33
34 var user = await UserStore.GetByApiKeyAsync(_apiKey);
35 if (user is null)
36 {
37 _error = "Invalid API key.";
38 return;
39 }
40
41 var claims = new[]
42 {
43 new Claim(ClaimTypes.NameIdentifier, user.Id),
44 new Claim(ClaimTypes.Name, user.Name),
45 new Claim(ClaimTypes.Role, user.Role.ToString())
46 };
47 var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
48 var principal = new ClaimsPrincipal(identity);
49
50 var ctx = HttpContextAccessor.HttpContext!;
51 await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
52
53 Nav.NavigateTo("/app", forceLoad: true);
54 }
55}
56
+ src/Catena.Web/Components/Pages/Overlaps.razor
1@page "/app/projects/{ProjectId}/overlaps"
2@using Catena.Shared.Overlap
3@inject PatchStore PatchStore
4
5<h1>Overlaps</h1>
6
7@if (_reports is null)
8{
9 <p>Loading...</p>
10}
11else if (_reports.Count == 0)
12{
13 <p>No overlaps between proposed patches.</p>
14}
15else
16{
17 <table class="data-table">
18 <thead>
19 <tr><th>Patch A</th><th>Patch B</th><th>Conflicts</th><th>Auto-Apply</th><th>Deduplicated</th></tr>
20 </thead>
21 <tbody>
22 @foreach (var r in _reports)
23 {
24 <tr class="@(r.HasConflicts ? "row-conflict" : "")">
25 <td><a href="/app/projects/@ProjectId/patches/@r.PatchIdA"><code>@r.PatchIdA[..8]</code></a></td>
26 <td><a href="/app/projects/@ProjectId/patches/@r.PatchIdB"><code>@r.PatchIdB[..8]</code></a></td>
27 <td class="@(r.Conflicts.Any() ? "text-red" : "")">@r.Conflicts.Count()</td>
28 <td>@r.AutoApplies.Count()</td>
29 <td>@r.Deduplications.Count()</td>
30 </tr>
31 }
32 </tbody>
33 </table>
34}
35
36@code {
37 [Parameter] public string ProjectId { get; set; } = "";
38
39 private List<PatchOverlapReport>? _reports;
40
41 protected override async Task OnInitializedAsync()
42 {
43 var proposed = await PatchStore.ListAsync(ProjectId, maturityFilter: Maturity.Proposed);
44 _reports = [];
45
46 for (int i = 0; i < proposed.Count; i++)
47 {
48 for (int j = i + 1; j < proposed.Count; j++)
49 {
50 var report = OverlapDetector.Detect(proposed[i], proposed[j]);
51 if (report.Results.Count > 0)
52 _reports.Add(report);
53 }
54 }
55 }
56}
57
+ src/Catena.Web/Components/Pages/PatchDetail.razor
1@page "/app/projects/{ProjectId}/patches/{PatchId}"
2@using Microsoft.AspNetCore.Components.Authorization
3@inject PatchStore PatchStore
4@inject TrunkState TrunkState
5@inject AuthenticationStateProvider AuthState
6
7<h1>Patch @PatchId[..Math.Min(8, PatchId.Length)]</h1>
8
9@if (_patch is null)
10{
11 <p>Loading...</p>
12}
13else
14{
15 <div class="patch-info">
16 <p><strong>Author:</strong> @_patch.Author</p>
17 <p><strong>Description:</strong> @_patch.Description</p>
18 <p><strong>Created:</strong> @_patch.Timestamp.ToString("yyyy-MM-dd HH:mm")</p>
19 <p><strong>Status:</strong> <MaturityBadge Value="@_patch.Maturity" /></p>
20 <p><strong>Base Hash:</strong> <code>@(_patch.BaseHash ?? "none")</code></p>
21 </div>
22
23 <h2>Operations</h2>
24 <OpsList Operations="@_patch.Ops" />
25
26 <div class="actions">
27 @if (_patch.Maturity is Maturity.DraftSynced or Maturity.DraftShared)
28 {
29 <button class="btn propose" @onclick="Propose">Propose</button>
30 }
31 @if (_patch.Maturity == Maturity.Proposed && _isMaintainer)
32 {
33 <button class="btn accept" @onclick="Accept">Accept</button>
34 }
35 @if (_patch.Maturity == Maturity.Accepted && _isMaintainer)
36 {
37 <button class="btn revert" @onclick="Revert">Revert</button>
38 }
39 </div>
40
41 @if (_message is not null)
42 {
43 <p class="action-message">@_message</p>
44 }
45}
46
47@code {
48 [Parameter] public string ProjectId { get; set; } = "";
49 [Parameter] public string PatchId { get; set; } = "";
50
51 private Patch? _patch;
52 private bool _isMaintainer;
53 private string? _message;
54
55 protected override async Task OnInitializedAsync()
56 {
57 _patch = await PatchStore.GetAsync(ProjectId, PatchId);
58
59 var authState = await AuthState.GetAuthenticationStateAsync();
60 var role = authState.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
61 _isMaintainer = role is "Maintainer" or "Admin";
62 }
63
64 private async Task Propose()
65 {
66 var (updated, error) = await PatchStore.UpdateMaturityAsync(ProjectId, PatchId, Maturity.Proposed);
67 if (error is not null) { _message = $"Error: {error}"; return; }
68 _patch = updated;
69 _message = "Patch proposed.";
70 }
71
72 private async Task Accept()
73 {
74 var (updated, error) = await PatchStore.UpdateMaturityAsync(ProjectId, PatchId, Maturity.Accepted);
75 if (error is not null) { _message = $"Error: {error}"; return; }
76 await TrunkState.ApplyPatchAsync(ProjectId, updated!, PatchStore);
77 _patch = updated;
78 _message = "Patch accepted. Trunk updated.";
79 }
80
81 private async Task Revert()
82 {
83 // Create inverse patch — simplified, reuses server logic pattern
84 _message = "Revert creates a new patch. Use the CLI for now.";
85 await Task.CompletedTask;
86 }
87}
88
+ src/Catena.Web/Components/Pages/Patches.razor
1@page "/app/projects/{ProjectId}/patches"
2@inject PatchStore PatchStore
3
4<h1>Patches</h1>
5
6<div class="tabs">
7 @foreach (var tab in _tabs)
8 {
9 <button class="@(tab == _activeTab ? "tab active" : "tab")" @onclick="() => SetTab(tab)">
10 @tab
11 </button>
12 }
13</div>
14
15@if (_patches is null)
16{
17 <p>Loading...</p>
18}
19else if (_patches.Count == 0)
20{
21 <p>No patches.</p>
22}
23else
24{
25 <table class="data-table">
26 <thead>
27 <tr><th>ID</th><th>Author</th><th>Description</th><th>Ops</th><th>Time</th><th>Status</th></tr>
28 </thead>
29 <tbody>
30 @foreach (var p in _patches)
31 {
32 <tr>
33 <td><a href="/app/projects/@ProjectId/patches/@p.Id"><code>@p.Id[..8]</code></a></td>
34 <td>@p.Author</td>
35 <td>@p.Description</td>
36 <td>@p.Ops.Count</td>
37 <td>@p.Timestamp.ToString("MM-dd HH:mm")</td>
38 <td><MaturityBadge Value="@p.Maturity" /></td>
39 </tr>
40 }
41 </tbody>
42 </table>
43}
44
45@code {
46 [Parameter] public string ProjectId { get; set; } = "";
47
48 private readonly string[] _tabs = ["Drafts", "Proposed", "Accepted"];
49 private string _activeTab = "Proposed";
50 private List<Patch>? _patches;
51
52 protected override async Task OnInitializedAsync()
53 {
54 await LoadPatches();
55 }
56
57 private async Task SetTab(string tab)
58 {
59 _activeTab = tab;
60 await LoadPatches();
61 }
62
63 private async Task LoadPatches()
64 {
65 var filter = _activeTab switch
66 {
67 "Drafts" => (Maturity?)null,
68 "Proposed" => Maturity.Proposed,
69 "Accepted" => Maturity.Accepted,
70 _ => null
71 };
72
73 var all = await PatchStore.ListAsync(ProjectId, maturityFilter: filter);
74 _patches = _activeTab == "Drafts"
75 ? all.Where(p => p.Maturity is Maturity.DraftLocal or Maturity.DraftSynced or Maturity.DraftShared).ToList()
76 : all;
77 }
78}
79
+ src/Catena.Web/Components/Pages/ProjectDashboard.razor
1@page "/app/projects/{ProjectId}"
2@inject ProjectStore ProjectStore
3@inject PatchStore PatchStore
4@inject TrunkState TrunkState
5
6<h1>@(_project?.Name ?? "Loading...")</h1>
7
8@if (_project is not null)
9{
10 <div class="dashboard-stats">
11 <div class="stat">
12 <span class="stat-value">@_fileCount</span>
13 <span class="stat-label">Files in trunk</span>
14 </div>
15 <div class="stat">
16 <span class="stat-value">@_patchCount</span>
17 <span class="stat-label">Total patches</span>
18 </div>
19 <div class="stat">
20 <span class="stat-value">@_proposedCount</span>
21 <span class="stat-label">Proposed</span>
22 </div>
23 </div>
24
25 <h2>Quick Links</h2>
26 <ul>
27 <li><a href="/app/projects/@ProjectId/patches">Patches</a></li>
28 <li><a href="/app/projects/@ProjectId/overlaps">Overlaps</a></li>
29 <li><a href="/app/projects/@ProjectId/releases">Releases</a></li>
30 <li><a href="/app/projects/@ProjectId/history">History</a></li>
31 </ul>
32}
33
34@code {
35 [Parameter] public string ProjectId { get; set; } = "";
36
37 private Project? _project;
38 private int _fileCount;
39 private int _patchCount;
40 private int _proposedCount;
41
42 protected override async Task OnInitializedAsync()
43 {
44 _project = await ProjectStore.GetAsync(ProjectId);
45 if (_project is null) return;
46
47 var files = await TrunkState.GetFilesAsync(ProjectId);
48 _fileCount = files.Count;
49
50 var patches = await PatchStore.ListAsync(ProjectId);
51 _patchCount = patches.Count;
52 _proposedCount = patches.Count(p => p.Maturity == Maturity.Proposed);
53 }
54}
55
+ src/Catena.Web/Components/Pages/Projects.razor
1@page "/app"
2@inject ProjectStore ProjectStore
3
4<h1>Projects</h1>
5
6@if (_projects is null)
7{
8 <p>Loading...</p>
9}
10else if (_projects.Count == 0)
11{
12 <p>No projects yet.</p>
13}
14else
15{
16 <table class="data-table">
17 <thead>
18 <tr><th>Name</th><th>ID</th><th>Created</th></tr>
19 </thead>
20 <tbody>
21 @foreach (var p in _projects)
22 {
23 <tr>
24 <td><a href="/app/projects/@p.Id">@p.Name</a></td>
25 <td><code>@p.Id</code></td>
26 <td>@p.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
27 </tr>
28 }
29 </tbody>
30 </table>
31}
32
33@code {
34 private List<Project>? _projects;
35
36 protected override async Task OnInitializedAsync()
37 {
38 _projects = await ProjectStore.ListAsync();
39 }
40}
41
+ src/Catena.Web/Components/Pages/Releases.razor
1@page "/app/projects/{ProjectId}/releases"
2@inject ReleaseStore ReleaseStore
3@inject TrunkState TrunkState
4
5<h1>Releases</h1>
6
7@if (_showCreate)
8{
9 <div class="create-form">
10 <input type="text" @bind="_version" placeholder="v1.0.0" />
11 <button class="btn accept" @onclick="CreateRelease">Create Release</button>
12 <button class="btn" @onclick="() => _showCreate = false">Cancel</button>
13 </div>
14 @if (_error is not null)
15 {
16 <p class="error">@_error</p>
17 }
18}
19else
20{
21 <button class="btn propose" @onclick="() => _showCreate = true">New Release</button>
22}
23
24@if (_releases is null)
25{
26 <p>Loading...</p>
27}
28else if (_releases.Count == 0)
29{
30 <p>No releases yet.</p>
31}
32else
33{
34 <table class="data-table">
35 <thead>
36 <tr><th>Version</th><th>Files</th><th>Trunk Hash</th><th>Created</th></tr>
37 </thead>
38 <tbody>
39 @foreach (var r in _releases)
40 {
41 <tr>
42 <td><strong>@r.Version</strong></td>
43 <td>@r.Files.Count</td>
44 <td><code>@r.TrunkHash</code></td>
45 <td>@r.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
46 </tr>
47 }
48 </tbody>
49 </table>
50}
51
52@code {
53 [Parameter] public string ProjectId { get; set; } = "";
54
55 private List<Release>? _releases;
56 private bool _showCreate;
57 private string _version = "";
58 private string? _error;
59
60 protected override async Task OnInitializedAsync()
61 {
62 _releases = await ReleaseStore.ListAsync(ProjectId);
63 }
64
65 private async Task CreateRelease()
66 {
67 if (string.IsNullOrWhiteSpace(_version))
68 {
69 _error = "Version is required.";
70 return;
71 }
72
73 if (ReleaseStore.Exists(ProjectId, _version))
74 {
75 _error = $"Release {_version} already exists.";
76 return;
77 }
78
79 var files = await TrunkState.GetFilesAsync(ProjectId);
80 var hash = TrunkState.ComputeHash(files);
81 await ReleaseStore.CreateAsync(ProjectId, _version, files, hash);
82
83 _showCreate = false;
84 _version = "";
85 _error = null;
86 _releases = await ReleaseStore.ListAsync(ProjectId);
87 }
88}
89
+ src/Catena.Web/Components/Pages/Users.razor
1@page "/app/admin/users"
2@using Microsoft.AspNetCore.Components.Authorization
3@inject UserStore UserStore
4@inject AuthenticationStateProvider AuthState
5
6<h1>User Management</h1>
7
8@if (!_isAdmin)
9{
10 <p>Admin access required.</p>
11}
12else
13{
14 @if (_showCreate)
15 {
16 <div class="create-form">
17 <input type="text" @bind="_newName" placeholder="Username" />
18 <select @bind="_newRole">
19 <option value="Developer">Developer</option>
20 <option value="Maintainer">Maintainer</option>
21 <option value="Admin">Admin</option>
22 </select>
23 <button class="btn accept" @onclick="CreateUser">Create</button>
24 <button class="btn" @onclick="() => _showCreate = false">Cancel</button>
25 </div>
26
27 @if (_createdKey is not null)
28 {
29 <div class="key-display">
30 <strong>API Key (save now — shown only once):</strong>
31 <code>@_createdKey</code>
32 </div>
33 }
34 }
35 else
36 {
37 <button class="btn propose" @onclick="() => _showCreate = true">Create User</button>
38 }
39
40 @if (_users is not null)
41 {
42 <table class="data-table">
43 <thead>
44 <tr><th>Name</th><th>Role</th><th>Created</th><th>ID</th></tr>
45 </thead>
46 <tbody>
47 @foreach (var u in _users)
48 {
49 <tr>
50 <td>@u.Name</td>
51 <td><span class="badge @u.Role.ToString().ToLower()">@u.Role</span></td>
52 <td>@u.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
53 <td><code>@u.Id</code></td>
54 </tr>
55 }
56 </tbody>
57 </table>
58 }
59}
60
61@code {
62 private List<User>? _users;
63 private bool _isAdmin;
64 private bool _showCreate;
65 private string _newName = "";
66 private string _newRole = "Developer";
67 private string? _createdKey;
68
69 protected override async Task OnInitializedAsync()
70 {
71 var authState = await AuthState.GetAuthenticationStateAsync();
72 var role = authState.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
73 _isAdmin = role == "Admin";
74
75 if (_isAdmin)
76 _users = await UserStore.ListAsync();
77 }
78
79 private async Task CreateUser()
80 {
81 if (string.IsNullOrWhiteSpace(_newName)) return;
82 var role = Enum.Parse<UserRole>(_newRole);
83 var (_, key) = await UserStore.CreateAsync(_newName, role);
84 _createdKey = key;
85 _newName = "";
86 _users = await UserStore.ListAsync();
87 }
88}
89
+ src/Catena.Web/Components/Routes.razor
1@using Catena.Web.Components.Layout
2
3<Router AppAssembly="typeof(Routes).Assembly">
4 <Found Context="routeData">
5 <RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
6 <FocusOnNavigate RouteData="routeData" Selector="h1" />
7 </Found>
8 <NotFound>
9 <LayoutView Layout="typeof(MainLayout)">
10 <p>Page not found.</p>
11 </LayoutView>
12 </NotFound>
13</Router>
14
+ src/Catena.Web/Components/Shared/MaturityBadge.razor
1@code {
2 [Parameter] public required Maturity Value { get; set; }
3
4 private string CssClass => Value switch
5 {
6 Maturity.DraftLocal => "badge draft",
7 Maturity.DraftSynced => "badge synced",
8 Maturity.DraftShared => "badge shared",
9 Maturity.Proposed => "badge proposed",
10 Maturity.Accepted => "badge accepted",
11 _ => "badge"
12 };
13}
14
15<span class="@CssClass">@Value</span>
16
+ src/Catena.Web/Components/Shared/OpsList.razor
1@code {
2 [Parameter] public required List<Operation> Operations { get; set; }
3
4 private static string Prefix(OpType type) => type switch
5 {
6 OpType.Insert => "+",
7 OpType.Modify => "~",
8 OpType.Delete => "-",
9 OpType.Rename or OpType.Move => ">",
10 _ => "?"
11 };
12
13 private static string FormatBytes(long bytes) => bytes switch
14 {
15 < 1024 => $"{bytes} B",
16 < 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
17 _ => $"{bytes / (1024.0 * 1024.0):F1} MB"
18 };
19}
20
21<table class="ops-table">
22 <thead>
23 <tr><th></th><th>File</th><th>Size</th></tr>
24 </thead>
25 <tbody>
26 @foreach (var op in Operations)
27 {
28 var size = op.EndByte - op.StartByte;
29 <tr class="op-@op.Type.ToString().ToLower()">
30 <td>@Prefix(op.Type)</td>
31 <td>
32 @op.File
33 @if (op.NewPath is not null)
34 {
35 <span> &rarr; @op.NewPath</span>
36 }
37 </td>
38 <td>@(size > 0 ? FormatBytes(size) : "")</td>
39 </tr>
40 }
41 </tbody>
42</table>
43
+ src/Catena.Web/Components/_Imports.razor
1@using Microsoft.AspNetCore.Components
2@using Microsoft.AspNetCore.Components.Authorization
3@using Microsoft.AspNetCore.Components.Forms
4@using Microsoft.AspNetCore.Components.Routing
5@using Microsoft.AspNetCore.Components.Web
6@using Catena.Shared.Models
7@using Catena.Shared.Dtos
8@using Catena.Storage
9@using Microsoft.AspNetCore.Http
10@using Catena.Web.Components.Shared
11
+ src/Catena.Web/Extensions/WebServiceExtensions.cs
1using Catena.Web.Components;
2using Microsoft.AspNetCore.Builder;
3using Microsoft.Extensions.DependencyInjection;
4
5namespace Catena.Web.Extensions;
6
7public static class WebServiceExtensions
8{
9 public static IServiceCollection AddCatenaWeb(this IServiceCollection services)
10 {
11 services.AddRazorComponents()
12 .AddInteractiveServerComponents();
13 services.AddCascadingAuthenticationState();
14 services.AddHttpContextAccessor();
15 return services;
16 }
17
18 public static WebApplication MapCatenaWeb(this WebApplication app)
19 {
20 app.MapStaticAssets();
21 app.MapRazorComponents<App>()
22 .AddInteractiveServerRenderMode();
23 return app;
24 }
25}
26
+ src/Catena.Web/wwwroot/css/app.css
1:root {
2 --bg: #1a1a2e;
3 --surface: #16213e;
4 --border: #2a2a4a;
5 --text: #e0e0e0;
6 --text-muted: #8888aa;
7 --accent: #e94560;
8 --green: #4ecca3;
9 --yellow: #ffc857;
10 --blue: #4ea8de;
11 --red: #e94560;
12}
13
14* { box-sizing: border-box; margin: 0; padding: 0; }
15
16body {
17 font-family: 'Segoe UI', system-ui, sans-serif;
18 background: var(--bg);
19 color: var(--text);
20}
21
22.app {
23 display: flex;
24 min-height: 100vh;
25}
26
27.sidebar {
28 width: 220px;
29 background: var(--surface);
30 border-right: 1px solid var(--border);
31 padding: 1rem;
32}
33
34.sidebar-header h2 {
35 color: var(--accent);
36 font-size: 1.4rem;
37 margin-bottom: 1.5rem;
38}
39
40.nav-menu {
41 list-style: none;
42}
43
44.nav-menu li a {
45 display: block;
46 padding: 0.5rem 0.75rem;
47 color: var(--text-muted);
48 text-decoration: none;
49 border-radius: 4px;
50 margin-bottom: 2px;
51}
52
53.nav-menu li a:hover,
54.nav-menu li a.active {
55 color: var(--text);
56 background: var(--border);
57}
58
59main {
60 flex: 1;
61 display: flex;
62 flex-direction: column;
63}
64
65.top-bar {
66 display: flex;
67 justify-content: flex-end;
68 align-items: center;
69 gap: 1rem;
70 padding: 0.75rem 1.5rem;
71 border-bottom: 1px solid var(--border);
72 background: var(--surface);
73}
74
75.top-bar .btn-link {
76 background: none;
77 border: none;
78 color: var(--text-muted);
79 cursor: pointer;
80 text-decoration: underline;
81}
82
83.content {
84 padding: 1.5rem;
85 max-width: 1100px;
86}
87
88h1 { margin-bottom: 1rem; font-size: 1.5rem; }
89h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); }
90
91a { color: var(--blue); text-decoration: none; }
92a:hover { text-decoration: underline; }
93code { font-family: 'Cascadia Code', monospace; font-size: 0.85em; color: var(--yellow); }
94
95/* Tables */
96.data-table {
97 width: 100%;
98 border-collapse: collapse;
99 margin-bottom: 1rem;
100}
101
102.data-table th, .data-table td {
103 padding: 0.5rem 0.75rem;
104 text-align: left;
105 border-bottom: 1px solid var(--border);
106}
107
108.data-table th {
109 color: var(--text-muted);
110 font-weight: 600;
111 font-size: 0.85rem;
112 text-transform: uppercase;
113}
114
115.data-table tr:hover {
116 background: rgba(255,255,255,0.03);
117}
118
119/* Ops table */
120.ops-table { width: 100%; border-collapse: collapse; }
121.ops-table td { padding: 0.3rem 0.5rem; font-family: monospace; font-size: 0.9rem; }
122.op-insert td:first-child { color: var(--green); }
123.op-modify td:first-child { color: var(--yellow); }
124.op-delete td:first-child { color: var(--red); }
125.op-rename td:first-child, .op-move td:first-child { color: var(--blue); }
126
127/* Badges */
128.badge {
129 display: inline-block;
130 padding: 2px 8px;
131 border-radius: 3px;
132 font-size: 0.75rem;
133 font-weight: 600;
134 text-transform: uppercase;
135}
136
137.badge.draft, .badge.synced { background: #333; color: #aaa; }
138.badge.shared { background: #1a3a5c; color: var(--blue); }
139.badge.proposed { background: #3d2e00; color: var(--yellow); }
140.badge.accepted { background: #1a3d2e; color: var(--green); }
141
142/* Dashboard */
143.dashboard-stats {
144 display: flex;
145 gap: 1.5rem;
146 margin-bottom: 1.5rem;
147}
148
149.stat {
150 background: var(--surface);
151 border: 1px solid var(--border);
152 border-radius: 6px;
153 padding: 1rem 1.5rem;
154 text-align: center;
155}
156
157.stat-value { display: block; font-size: 2rem; font-weight: 700; color: var(--accent); }
158.stat-label { font-size: 0.85rem; color: var(--text-muted); }
159
160/* Tabs */
161.tabs { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 1px solid var(--border); }
162.tab {
163 padding: 0.5rem 1rem;
164 background: none;
165 border: none;
166 color: var(--text-muted);
167 cursor: pointer;
168 border-bottom: 2px solid transparent;
169}
170.tab.active { color: var(--text); border-bottom-color: var(--accent); }
171.tab:hover { color: var(--text); }
172
173/* Actions */
174.actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
175.btn {
176 padding: 0.5rem 1rem;
177 border: none;
178 border-radius: 4px;
179 cursor: pointer;
180 font-weight: 600;
181}
182.btn.propose { background: var(--yellow); color: #000; }
183.btn.accept { background: var(--green); color: #000; }
184.btn.revert { background: var(--red); color: #fff; }
185.action-message { margin-top: 0.5rem; color: var(--text-muted); }
186
187/* Patch info */
188.patch-info p { margin-bottom: 0.3rem; }
189.patch-info strong { color: var(--text-muted); }
190
191/* Forms */
192form { display: flex; flex-direction: column; gap: 0.5rem; max-width: 400px; }
193label { color: var(--text-muted); font-size: 0.9rem; }
194input[type="password"], input[type="text"] {
195 padding: 0.5rem;
196 background: var(--surface);
197 border: 1px solid var(--border);
198 border-radius: 4px;
199 color: var(--text);
200}
201form button[type="submit"] {
202 padding: 0.5rem 1rem;
203 background: var(--accent);
204 color: #fff;
205 border: none;
206 border-radius: 4px;
207 cursor: pointer;
208 font-weight: 600;
209}
210.error { color: var(--red); margin-bottom: 0.5rem; }
211
212ul { padding-left: 1.5rem; }
213li { margin-bottom: 0.3rem; }
214
+ tests/Catena.Tests/AuthApiTests.cs
1using System.Net;
2using System.Net.Http.Json;
3using Catena.Shared.Dtos;
4using Catena.Shared.Models;
5using Catena.Storage;
6using Microsoft.AspNetCore.Mvc.Testing;
7using Microsoft.Extensions.DependencyInjection;
8
9namespace Catena.Tests;
10
11public class AuthApiTests
12{
13 private readonly HttpClient _client;
14 private readonly string _adminKey;
15 private readonly string _devKey;
16
17 public AuthApiTests()
18 {
19 var dataDir = Path.Combine(Path.GetTempPath(), $"catena-auth-test-{Guid.NewGuid():N}");
20 var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
21 {
22 builder.UseSetting("Catena:DataDir", dataDir);
23 builder.UseSetting("Catena:AuthEnabled", "true");
24 });
25
26 // Seed users
27 var userStore = new UserStore(Path.Combine(dataDir, "users"));
28 var (_, adminKey) = userStore.CreateAsync("admin", UserRole.Admin).Result;
29 var (_, devKey) = userStore.CreateAsync("dev", UserRole.Developer).Result;
30 _adminKey = adminKey;
31 _devKey = devKey;
32
33 _client = factory.CreateClient();
34 }
35
36 private HttpRequestMessage WithKey(HttpMethod method, string url, string key)
37 {
38 var msg = new HttpRequestMessage(method, url);
39 msg.Headers.Add("X-Api-Key", key);
40 return msg;
41 }
42
43 [Fact]
44 public async Task GetMe_WithValidKey_ReturnsUser()
45 {
46 var msg = WithKey(HttpMethod.Get, "/auth/me", _adminKey);
47 var response = await _client.SendAsync(msg);
48
49 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
50 var user = await response.Content.ReadFromJsonAsync<LoginResponse>();
51 Assert.Equal("admin", user!.Name);
52 Assert.Equal(UserRole.Admin, user.Role);
53 }
54
55 [Fact]
56 public async Task GetMe_WithoutKey_Returns401()
57 {
58 var response = await _client.GetAsync("/auth/me");
59 Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
60 }
61
62 [Fact]
63 public async Task GetMe_WithInvalidKey_Returns401()
64 {
65 var msg = WithKey(HttpMethod.Get, "/auth/me", "invalid-key");
66 var response = await _client.SendAsync(msg);
67 Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
68 }
69
70 [Fact]
71 public async Task Login_WithValidKey_ReturnsUser()
72 {
73 var response = await _client.PostAsJsonAsync("/auth/login", new LoginRequest(_devKey));
74
75 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
76 var user = await response.Content.ReadFromJsonAsync<LoginResponse>();
77 Assert.Equal("dev", user!.Name);
78 }
79
80 [Fact]
81 public async Task Login_WithInvalidKey_Returns401()
82 {
83 var response = await _client.PostAsJsonAsync("/auth/login", new LoginRequest("wrong"));
84 Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
85 }
86
87 // === User Management (Admin only) ===
88
89 [Fact]
90 public async Task CreateUser_AsAdmin_Succeeds()
91 {
92 var msg = WithKey(HttpMethod.Post, "/users", _adminKey);
93 msg.Content = JsonContent.Create(new CreateUserRequest("newuser", UserRole.Developer));
94
95 var response = await _client.SendAsync(msg);
96
97 Assert.Equal(HttpStatusCode.Created, response.StatusCode);
98 var result = await response.Content.ReadFromJsonAsync<CreateUserResponse>();
99 Assert.NotNull(result);
100 Assert.Equal("newuser", result.Name);
101 Assert.NotEmpty(result.ApiKey);
102 }
103
104 [Fact]
105 public async Task CreateUser_AsDeveloper_ReturnsForbidden()
106 {
107 var msg = WithKey(HttpMethod.Post, "/users", _devKey);
108 msg.Content = JsonContent.Create(new CreateUserRequest("hacker", UserRole.Admin));
109
110 var response = await _client.SendAsync(msg);
111
112 Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
113 }
114
115 [Fact]
116 public async Task CreateUser_NoAuth_Returns401()
117 {
118 var response = await _client.PostAsJsonAsync("/users", new CreateUserRequest("anon", UserRole.Developer));
119 Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
120 }
121
122 [Fact]
123 public async Task ListUsers_AsAdmin_ReturnsAll()
124 {
125 var msg = WithKey(HttpMethod.Get, "/users", _adminKey);
126 var response = await _client.SendAsync(msg);
127
128 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
129 var users = await response.Content.ReadFromJsonAsync<List<UserResponse>>();
130 Assert.NotNull(users);
131 Assert.True(users.Count >= 2); // admin + dev
132 }
133}
134
+ tests/Catena.Tests/Catena.Tests.csproj
1<Project Sdk="Microsoft.NET.Sdk">
2
3 <PropertyGroup>
4 <TargetFramework>net10.0</TargetFramework>
5 <ImplicitUsings>enable</ImplicitUsings>
6 <Nullable>enable</Nullable>
7 <IsPackable>false</IsPackable>
8 </PropertyGroup>
9
10 <ItemGroup>
11 <PackageReference Include="coverlet.collector" Version="6.0.4" />
12 <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
13 <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
14 <PackageReference Include="xunit" Version="2.9.3" />
15 <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
16 </ItemGroup>
17
18 <ItemGroup>
19 <Using Include="Xunit" />
20 </ItemGroup>
21
22 <ItemGroup>
23 <ProjectReference Include="..\..\src\Catena.Shared\Catena.Shared.csproj" />
24 <ProjectReference Include="..\..\src\Catena.Server\Catena.Server.csproj" />
25 <ProjectReference Include="..\..\src\Catena.Storage\Catena.Storage.csproj" />
26 </ItemGroup>
27
28</Project>
+ tests/Catena.Tests/DependencyTests.cs
1using Catena.Shared.Dtos;
2using Catena.Shared.Models;
3using Catena.Storage;
4
5namespace Catena.Tests;
6
7public class DependencyTests : IDisposable
8{
9 private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"catena-dep-test-{Guid.NewGuid():N}");
10 private readonly PatchStore _patchStore;
11 private readonly ProjectStore _projectStore;
12
13 public DependencyTests()
14 {
15 _patchStore = new PatchStore(_tempDir);
16 _projectStore = new ProjectStore(_tempDir);
17 }
18
19 public void Dispose()
20 {
21 if (Directory.Exists(_tempDir))
22 Directory.Delete(_tempDir, true);
23 }
24
25 private CreatePatchRequest MakeRequest(string desc, Maturity maturity = Maturity.DraftSynced, List<string>? deps = null, List<string>? targets = null) => new()
26 {
27 Author = "user",
28 Description = desc,
29 Maturity = maturity,
30 Dependencies = deps ?? [],
31 Targets = targets ?? ["trunk"],
32 Ops = [new FileOperation { Type = OpType.Insert, File = $"{desc}.txt", ContentBase64 = Convert.ToBase64String("x"u8.ToArray()) }]
33 };
34
35 [Fact]
36 public async Task Accept_WithUnacceptedDependency_Fails()
37 {
38 var project = await _projectStore.CreateAsync("Test");
39 var patchA = await _patchStore.CreateAsync(project.Id, MakeRequest("A"));
40 var patchB = await _patchStore.CreateAsync(project.Id, MakeRequest("B", deps: [patchA.Id]));
41
42 // Propose both
43 await _patchStore.UpdateMaturityAsync(project.Id, patchA.Id, Maturity.Proposed);
44 await _patchStore.UpdateMaturityAsync(project.Id, patchB.Id, Maturity.Proposed);
45
46 // Try to accept B before A
47 var (_, error) = await _patchStore.UpdateMaturityAsync(project.Id, patchB.Id, Maturity.Accepted);
48
49 Assert.NotNull(error);
50 Assert.Contains("must be accepted first", error);
51 }
52
53 [Fact]
54 public async Task Accept_WithAcceptedDependency_Succeeds()
55 {
56 var project = await _projectStore.CreateAsync("Test");
57 var patchA = await _patchStore.CreateAsync(project.Id, MakeRequest("A"));
58 var patchB = await _patchStore.CreateAsync(project.Id, MakeRequest("B", deps: [patchA.Id]));
59
60 await _patchStore.UpdateMaturityAsync(project.Id, patchA.Id, Maturity.Proposed);
61 await _patchStore.UpdateMaturityAsync(project.Id, patchA.Id, Maturity.Accepted);
62 await _patchStore.UpdateMaturityAsync(project.Id, patchB.Id, Maturity.Proposed);
63
64 var (updated, error) = await _patchStore.UpdateMaturityAsync(project.Id, patchB.Id, Maturity.Accepted);
65
66 Assert.Null(error);
67 Assert.NotNull(updated);
68 Assert.Equal(Maturity.Accepted, updated.Maturity);
69 }
70
71 [Fact]
72 public async Task Accept_WithMultipleDeps_AllMustBeAccepted()
73 {
74 var project = await _projectStore.CreateAsync("Test");
75 var patchA = await _patchStore.CreateAsync(project.Id, MakeRequest("A"));
76 var patchB = await _patchStore.CreateAsync(project.Id, MakeRequest("B"));
77 var patchC = await _patchStore.CreateAsync(project.Id, MakeRequest("C", deps: [patchA.Id, patchB.Id]));
78
79 await _patchStore.UpdateMaturityAsync(project.Id, patchA.Id, Maturity.Proposed);
80 await _patchStore.UpdateMaturityAsync(project.Id, patchA.Id, Maturity.Accepted);
81 await _patchStore.UpdateMaturityAsync(project.Id, patchB.Id, Maturity.Proposed);
82 // B not accepted yet
83 await _patchStore.UpdateMaturityAsync(project.Id, patchC.Id, Maturity.Proposed);
84
85 var (_, error) = await _patchStore.UpdateMaturityAsync(project.Id, patchC.Id, Maturity.Accepted);
86
87 Assert.NotNull(error);
88 Assert.Contains("must be accepted first", error);
89 }
90
91 [Fact]
92 public async Task Accept_NoDependencies_Succeeds()
93 {
94 var project = await _projectStore.CreateAsync("Test");
95 var patch = await _patchStore.CreateAsync(project.Id, MakeRequest("A"));
96
97 await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Proposed);
98 var (updated, error) = await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Accepted);
99
100 Assert.Null(error);
101 Assert.Equal(Maturity.Accepted, updated!.Maturity);
102 }
103
104 [Fact]
105 public async Task Accept_UnknownDependency_Fails()
106 {
107 var project = await _projectStore.CreateAsync("Test");
108 var patch = await _patchStore.CreateAsync(project.Id, MakeRequest("A", deps: ["doesnotexist"]));
109
110 await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Proposed);
111 var (_, error) = await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Accepted);
112
113 Assert.NotNull(error);
114 Assert.Contains("not found", error);
115 }
116
117 [Fact]
118 public async Task Patch_PreservesTargets()
119 {
120 var project = await _projectStore.CreateAsync("Test");
121 var patch = await _patchStore.CreateAsync(project.Id, MakeRequest("hotfix", targets: ["trunk", "v1.0.0"]));
122
123 var loaded = await _patchStore.GetAsync(project.Id, patch.Id);
124 Assert.NotNull(loaded);
125 Assert.Contains("trunk", loaded.Targets);
126 Assert.Contains("v1.0.0", loaded.Targets);
127 }
128
129 [Fact]
130 public async Task Patch_PreservesDependencies()
131 {
132 var project = await _projectStore.CreateAsync("Test");
133 var patchA = await _patchStore.CreateAsync(project.Id, MakeRequest("A"));
134 var patchB = await _patchStore.CreateAsync(project.Id, MakeRequest("B", deps: [patchA.Id]));
135
136 var loaded = await _patchStore.GetAsync(project.Id, patchB.Id);
137 Assert.NotNull(loaded);
138 Assert.Single(loaded.Dependencies);
139 Assert.Equal(patchA.Id, loaded.Dependencies[0]);
140 }
141}
142
+ tests/Catena.Tests/FileTreeApiTests.cs
1using System.Net;
2using System.Net.Http.Json;
3using Catena.Shared.Dtos;
4using Catena.Shared.Models;
5using Microsoft.AspNetCore.Mvc.Testing;
6
7namespace Catena.Tests;
8
9public class FileTreeApiTests
10{
11 private readonly HttpClient _client;
12
13 public FileTreeApiTests()
14 {
15 var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
16 {
17 builder.UseSetting("Catena:AuthEnabled", "false");
18 builder.UseSetting("Catena:DataDir",
19 Path.Combine(Path.GetTempPath(), $"catena-filetree-test-{Guid.NewGuid():N}"));
20 });
21 _client = factory.CreateClient();
22 }
23
24 private async Task<string> SetupProjectWithFiles()
25 {
26 var resp = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("Test", true));
27 var project = await resp.Content.ReadFromJsonAsync<ProjectResponse>();
28 var projectId = project!.Id;
29
30 var patchReq = new CreatePatchRequest
31 {
32 Author = "user",
33 Description = "add files",
34 Maturity = Maturity.DraftSynced,
35 Ops =
36 [
37 new FileOperation { Type = OpType.Insert, File = "src/app.cs", ContentBase64 = Convert.ToBase64String("code"u8.ToArray()) },
38 new FileOperation { Type = OpType.Insert, File = "src/models/user.cs", ContentBase64 = Convert.ToBase64String("model"u8.ToArray()) },
39 new FileOperation { Type = OpType.Insert, File = "readme.md", ContentBase64 = Convert.ToBase64String("# Hello"u8.ToArray()) }
40 ]
41 };
42 var pResp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", patchReq);
43 var patch = await pResp.Content.ReadFromJsonAsync<PatchResponse>();
44 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch!.Id}/maturity", new UpdateMaturityRequest(Maturity.Proposed));
45 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch.Id}/maturity", new UpdateMaturityRequest(Maturity.Accepted));
46
47 return projectId;
48 }
49
50 [Fact]
51 public async Task FileTree_ReturnsHierarchy()
52 {
53 var projectId = await SetupProjectWithFiles();
54
55 var tree = await _client.GetFromJsonAsync<List<FileTreeEntry>>($"/projects/{projectId}/trunk/tree");
56
57 Assert.NotNull(tree);
58 Assert.True(tree.Count >= 2); // "src/" dir + "readme.md" file
59 Assert.Contains(tree, e => e.Name == "src" && e.IsDirectory);
60 Assert.Contains(tree, e => e.Name == "readme.md" && !e.IsDirectory);
61 }
62
63 [Fact]
64 public async Task FileTree_SubdirectoryHasChildren()
65 {
66 var projectId = await SetupProjectWithFiles();
67
68 var tree = await _client.GetFromJsonAsync<List<FileTreeEntry>>($"/projects/{projectId}/trunk/tree");
69 var srcDir = tree!.First(e => e.Name == "src");
70
71 Assert.True(srcDir.Children.Count >= 1);
72 Assert.Contains(srcDir.Children, e => e.Name == "app.cs");
73 }
74
75 [Fact]
76 public async Task LineCount_TrackedOnPatch()
77 {
78 var projectId = await SetupProjectWithFiles();
79
80 var patches = await _client.GetFromJsonAsync<List<PatchResponse>>($"/projects/{projectId}/patches");
81 var diff = await _client.GetFromJsonAsync<PatchDiffResponse>($"/projects/{projectId}/patches/{patches![0].Id}/diff");
82
83 Assert.NotNull(diff);
84 // "# Hello" = 1 line, "code" = 1 line, "model" = 1 line
85 Assert.True(diff.Entries.Sum(e => e.LinesAdded) > 0);
86 }
87}
88
+ tests/Catena.Tests/HistoryAndDiffApiTests.cs
1using System.Net;
2using System.Net.Http.Json;
3using Catena.Shared.Dtos;
4using Catena.Shared.Models;
5using Microsoft.AspNetCore.Mvc.Testing;
6
7namespace Catena.Tests;
8
9public class HistoryAndDiffApiTests
10{
11 private readonly HttpClient _client;
12
13 public HistoryAndDiffApiTests()
14 {
15 var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
16 {
17 builder.UseSetting("Catena:AuthEnabled", "false");
18 builder.UseSetting("Catena:DataDir",
19 Path.Combine(Path.GetTempPath(), $"catena-history-test-{Guid.NewGuid():N}"));
20 });
21 _client = factory.CreateClient();
22 }
23
24 private async Task<string> CreateProjectAsync()
25 {
26 var response = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("Test"));
27 var project = await response.Content.ReadFromJsonAsync<ProjectResponse>();
28 return project!.Id;
29 }
30
31 private async Task<PatchResponse> CreateAcceptedPatchAsync(string projectId, string file, string content, string author = "user")
32 {
33 var request = new CreatePatchRequest
34 {
35 Author = author,
36 Description = $"add {file}",
37 Maturity = Maturity.DraftSynced,
38 Ops = [new FileOperation { Type = OpType.Insert, File = file, ContentBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content)) }]
39 };
40 var createResp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", request);
41 var patch = await createResp.Content.ReadFromJsonAsync<PatchResponse>();
42
43 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch!.Id}/maturity",
44 new UpdateMaturityRequest(Maturity.Proposed));
45 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch.Id}/maturity",
46 new UpdateMaturityRequest(Maturity.Accepted));
47
48 return patch;
49 }
50
51 // ===== HISTORY =====
52
53 [Fact]
54 public async Task History_EmptyProject_ReturnsEmpty()
55 {
56 var projectId = await CreateProjectAsync();
57 var history = await _client.GetFromJsonAsync<List<PatchResponse>>($"/projects/{projectId}/trunk/history");
58
59 Assert.NotNull(history);
60 Assert.Empty(history);
61 }
62
63 [Fact]
64 public async Task History_ReturnsAcceptedPatches()
65 {
66 var projectId = await CreateProjectAsync();
67 await CreateAcceptedPatchAsync(projectId, "a.txt", "aaa");
68 await CreateAcceptedPatchAsync(projectId, "b.txt", "bbb");
69
70 var history = await _client.GetFromJsonAsync<List<PatchResponse>>($"/projects/{projectId}/trunk/history");
71
72 Assert.NotNull(history);
73 Assert.Equal(2, history.Count);
74 }
75
76 [Fact]
77 public async Task History_ExcludesNonAccepted()
78 {
79 var projectId = await CreateProjectAsync();
80 await CreateAcceptedPatchAsync(projectId, "accepted.txt", "yes");
81
82 // Create a draft that's NOT accepted
83 var draft = new CreatePatchRequest
84 {
85 Author = "user",
86 Description = "just a draft",
87 Maturity = Maturity.DraftSynced,
88 Ops = [new FileOperation { Type = OpType.Insert, File = "draft.txt", ContentBase64 = Convert.ToBase64String("draft"u8.ToArray()) }]
89 };
90 await _client.PostAsJsonAsync($"/projects/{projectId}/patches", draft);
91
92 var history = await _client.GetFromJsonAsync<List<PatchResponse>>($"/projects/{projectId}/trunk/history");
93
94 Assert.Single(history!);
95 Assert.Contains("accepted.txt", history[0].Description);
96 }
97
98 [Fact]
99 public async Task History_FilterByFile()
100 {
101 var projectId = await CreateProjectAsync();
102 await CreateAcceptedPatchAsync(projectId, "target.txt", "t");
103 await CreateAcceptedPatchAsync(projectId, "other.txt", "o");
104
105 var history = await _client.GetFromJsonAsync<List<PatchResponse>>(
106 $"/projects/{projectId}/trunk/history?file=target.txt");
107
108 Assert.Single(history!);
109 Assert.Contains("target.txt", history[0].Description);
110 }
111
112 [Fact]
113 public async Task History_FilterByAuthor()
114 {
115 var projectId = await CreateProjectAsync();
116 await CreateAcceptedPatchAsync(projectId, "a.txt", "a", author: "alice");
117 await CreateAcceptedPatchAsync(projectId, "b.txt", "b", author: "bob");
118
119 var history = await _client.GetFromJsonAsync<List<PatchResponse>>(
120 $"/projects/{projectId}/trunk/history?author=alice");
121
122 Assert.Single(history!);
123 Assert.Equal("alice", history[0].Author);
124 }
125
126 [Fact]
127 public async Task History_UnknownProject_ReturnsNotFound()
128 {
129 var response = await _client.GetAsync("/projects/unknown/trunk/history");
130 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
131 }
132
133 // ===== DIFF =====
134
135 [Fact]
136 public async Task Diff_ReturnsOpsForPatch()
137 {
138 var projectId = await CreateProjectAsync();
139 var patch = await CreateAcceptedPatchAsync(projectId, "hello.txt", "hello world");
140
141 var diff = await _client.GetFromJsonAsync<PatchDiffResponse>(
142 $"/projects/{projectId}/patches/{patch.Id}/diff");
143
144 Assert.NotNull(diff);
145 Assert.Equal(patch.Id, diff.PatchId);
146 Assert.Single(diff.Entries);
147 Assert.Equal(OpType.Insert, diff.Entries[0].Type);
148 Assert.Equal("hello.txt", diff.Entries[0].File);
149 Assert.Equal(11, diff.Entries[0].Bytes); // "hello world" = 11 bytes
150 }
151
152 [Fact]
153 public async Task Diff_MultipleOps()
154 {
155 var projectId = await CreateProjectAsync();
156
157 var request = new CreatePatchRequest
158 {
159 Author = "user",
160 Description = "multi-op",
161 Maturity = Maturity.DraftSynced,
162 Ops =
163 [
164 new FileOperation { Type = OpType.Insert, File = "new.txt", ContentBase64 = Convert.ToBase64String("new"u8.ToArray()) },
165 new FileOperation { Type = OpType.Delete, File = "old.txt" }
166 ]
167 };
168 var resp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", request);
169 var patch = await resp.Content.ReadFromJsonAsync<PatchResponse>();
170
171 var diff = await _client.GetFromJsonAsync<PatchDiffResponse>(
172 $"/projects/{projectId}/patches/{patch!.Id}/diff");
173
174 Assert.Equal(2, diff!.Entries.Count);
175 Assert.Contains(diff.Entries, e => e.Type == OpType.Insert && e.File == "new.txt");
176 Assert.Contains(diff.Entries, e => e.Type == OpType.Delete && e.File == "old.txt");
177 }
178
179 [Fact]
180 public async Task Diff_RenameOp_ShowsNewPath()
181 {
182 var projectId = await CreateProjectAsync();
183
184 var request = new CreatePatchRequest
185 {
186 Author = "user",
187 Description = "rename",
188 Maturity = Maturity.DraftSynced,
189 Ops = [new FileOperation { Type = OpType.Rename, File = "old.txt", NewPath = "new.txt" }]
190 };
191 var resp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", request);
192 var patch = await resp.Content.ReadFromJsonAsync<PatchResponse>();
193
194 var diff = await _client.GetFromJsonAsync<PatchDiffResponse>(
195 $"/projects/{projectId}/patches/{patch!.Id}/diff");
196
197 Assert.Single(diff!.Entries);
198 Assert.Equal("old.txt", diff.Entries[0].File);
199 Assert.Equal("new.txt", diff.Entries[0].NewPath);
200 }
201
202 [Fact]
203 public async Task Diff_UnknownPatch_ReturnsNotFound()
204 {
205 var projectId = await CreateProjectAsync();
206 var response = await _client.GetAsync($"/projects/{projectId}/patches/unknown/diff");
207 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
208 }
209}
210
+ tests/Catena.Tests/IgnoreFilterTests.cs
1using Catena.Shared.Tracking;
2
3namespace Catena.Tests;
4
5public class IgnoreFilterTests
6{
7 [Theory]
8 [InlineData("bin/obj/file.dll", true)]
9 [InlineData("src/bin/debug/app.dll", true)]
10 [InlineData("binary.txt", false)]
11 public void DirectoryPattern_MatchesAnywhere(string path, bool expected)
12 {
13 var filter = new IgnoreFilter(["bin/"]);
14 Assert.Equal(expected, filter.IsIgnored(path));
15 }
16
17 [Theory]
18 [InlineData(".catena/config.json", true)]
19 [InlineData(".catena/state", true)]
20 [InlineData("src/.catena/nope", true)]
21 [InlineData("catena/file.cs", false)]
22 public void CatenaDir_AlwaysIgnored(string path, bool expected)
23 {
24 var filter = new IgnoreFilter([".catena/"]);
25 Assert.Equal(expected, filter.IsIgnored(path));
26 }
27
28 [Theory]
29 [InlineData("file.dll", true)]
30 [InlineData("lib/thing.dll", true)]
31 [InlineData("file.cs", false)]
32 public void WildcardPattern_MatchesFiles(string path, bool expected)
33 {
34 var filter = new IgnoreFilter(["*.dll"]);
35 Assert.Equal(expected, filter.IsIgnored(path));
36 }
37
38 [Fact]
39 public void EmptyLines_AndComments_AreSkipped()
40 {
41 var filter = new IgnoreFilter(["", " ", "# comment", "bin/"]);
42 Assert.False(filter.IsIgnored("src/main.cs"));
43 Assert.True(filter.IsIgnored("bin/debug/app.dll"));
44 }
45
46 [Fact]
47 public void BackslashPaths_AreNormalized()
48 {
49 var filter = new IgnoreFilter(["bin/"]);
50 Assert.True(filter.IsIgnored("src\\bin\\debug\\file.dll"));
51 }
52
53 [Fact]
54 public async Task LoadAsync_ReadsIgnoreFile()
55 {
56 var tempDir = Path.Combine(Path.GetTempPath(), $"catena-ignore-test-{Guid.NewGuid():N}");
57 var catenaDir = Path.Combine(tempDir, ".catena");
58 Directory.CreateDirectory(catenaDir);
59 await File.WriteAllTextAsync(Path.Combine(catenaDir, "ignore"), "logs/\n*.tmp\n");
60
61 try
62 {
63 var filter = await IgnoreFilter.LoadAsync(tempDir);
64 Assert.True(filter.IsIgnored(".catena/config.json")); // always ignored
65 Assert.True(filter.IsIgnored("logs/app.log"));
66 Assert.True(filter.IsIgnored("data/file.tmp"));
67 Assert.False(filter.IsIgnored("src/main.cs"));
68 }
69 finally
70 {
71 Directory.Delete(tempDir, true);
72 }
73 }
74
75 [Fact]
76 public async Task LoadAsync_WorksWithoutIgnoreFile()
77 {
78 var tempDir = Path.Combine(Path.GetTempPath(), $"catena-ignore-test-{Guid.NewGuid():N}");
79 Directory.CreateDirectory(tempDir);
80
81 try
82 {
83 var filter = await IgnoreFilter.LoadAsync(tempDir);
84 Assert.True(filter.IsIgnored(".catena/config.json")); // always ignored
85 Assert.False(filter.IsIgnored("src/main.cs"));
86 }
87 finally
88 {
89 Directory.Delete(tempDir, true);
90 }
91 }
92}
93
+ tests/Catena.Tests/MaturityApiTests.cs
1using System.Net;
2using System.Net.Http.Json;
3using Catena.Shared.Dtos;
4using Catena.Shared.Models;
5using Microsoft.AspNetCore.Mvc.Testing;
6
7namespace Catena.Tests;
8
9public class MaturityApiTests
10{
11 private readonly HttpClient _client;
12
13 public MaturityApiTests()
14 {
15 var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
16 {
17 builder.UseSetting("Catena:AuthEnabled", "false");
18 builder.UseSetting("Catena:DataDir",
19 Path.Combine(Path.GetTempPath(), $"catena-matapi-test-{Guid.NewGuid():N}"));
20 });
21 _client = factory.CreateClient();
22 }
23
24 private async Task<string> CreateProjectAsync()
25 {
26 var response = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("Test"));
27 var project = await response.Content.ReadFromJsonAsync<ProjectResponse>();
28 return project!.Id;
29 }
30
31 private async Task<PatchResponse> CreatePatchAsync(string projectId, Maturity maturity = Maturity.DraftSynced)
32 {
33 var request = new CreatePatchRequest
34 {
35 Author = "testuser",
36 Description = "test",
37 Maturity = maturity,
38 Ops = [new FileOperation { Type = OpType.Insert, File = "test.txt", ContentBase64 = Convert.ToBase64String("content"u8.ToArray()) }]
39 };
40 var response = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", request);
41 return (await response.Content.ReadFromJsonAsync<PatchResponse>())!;
42 }
43
44 [Fact]
45 public async Task Propose_UpdatesMaturity()
46 {
47 var projectId = await CreateProjectAsync();
48 var patch = await CreatePatchAsync(projectId);
49
50 var response = await _client.PutAsJsonAsync(
51 $"/projects/{projectId}/patches/{patch.Id}/maturity",
52 new UpdateMaturityRequest(Maturity.Proposed));
53
54 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
55 var updated = await response.Content.ReadFromJsonAsync<PatchResponse>();
56 Assert.Equal(Maturity.Proposed, updated!.Maturity);
57 }
58
59 [Fact]
60 public async Task Accept_UpdatesTrunk()
61 {
62 var projectId = await CreateProjectAsync();
63 var patch = await CreatePatchAsync(projectId);
64
65 await _client.PutAsJsonAsync(
66 $"/projects/{projectId}/patches/{patch.Id}/maturity",
67 new UpdateMaturityRequest(Maturity.Proposed));
68
69 await _client.PutAsJsonAsync(
70 $"/projects/{projectId}/patches/{patch.Id}/maturity",
71 new UpdateMaturityRequest(Maturity.Accepted));
72
73 var trunk = await _client.GetFromJsonAsync<TrunkStateResponse>($"/projects/{projectId}/trunk");
74 Assert.NotNull(trunk);
75 Assert.Single(trunk.Files);
76 Assert.True(trunk.Files.ContainsKey("test.txt"));
77 }
78
79 [Fact]
80 public async Task InvalidTransition_ReturnsBadRequest()
81 {
82 var projectId = await CreateProjectAsync();
83 var patch = await CreatePatchAsync(projectId);
84
85 var response = await _client.PutAsJsonAsync(
86 $"/projects/{projectId}/patches/{patch.Id}/maturity",
87 new UpdateMaturityRequest(Maturity.Accepted));
88
89 Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
90 }
91
92 [Fact]
93 public async Task UnknownPatch_ReturnsNotFound()
94 {
95 var projectId = await CreateProjectAsync();
96
97 var response = await _client.PutAsJsonAsync(
98 $"/projects/{projectId}/patches/doesnotexist/maturity",
99 new UpdateMaturityRequest(Maturity.Proposed));
100
101 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
102 }
103
104 [Fact]
105 public async Task FullFlow_DraftToAccepted_TrunkUpdated()
106 {
107 var projectId = await CreateProjectAsync();
108
109 // Create draft
110 var patch = await CreatePatchAsync(projectId);
111 Assert.Equal(Maturity.DraftSynced, patch.Maturity);
112
113 // Propose
114 var proposeResponse = await _client.PutAsJsonAsync(
115 $"/projects/{projectId}/patches/{patch.Id}/maturity",
116 new UpdateMaturityRequest(Maturity.Proposed));
117 var proposed = await proposeResponse.Content.ReadFromJsonAsync<PatchResponse>();
118 Assert.Equal(Maturity.Proposed, proposed!.Maturity);
119
120 // Accept
121 var acceptResponse = await _client.PutAsJsonAsync(
122 $"/projects/{projectId}/patches/{patch.Id}/maturity",
123 new UpdateMaturityRequest(Maturity.Accepted));
124 var accepted = await acceptResponse.Content.ReadFromJsonAsync<PatchResponse>();
125 Assert.Equal(Maturity.Accepted, accepted!.Maturity);
126
127 // Trunk has the file
128 var trunk = await _client.GetFromJsonAsync<TrunkStateResponse>($"/projects/{projectId}/trunk");
129 Assert.Single(trunk!.Files);
130
131 // Second draft after accept: status should reflect trunk
132 var patch2Request = new CreatePatchRequest
133 {
134 Author = "testuser",
135 Description = "second",
136 Maturity = Maturity.DraftSynced,
137 Ops = [new FileOperation { Type = OpType.Insert, File = "second.txt", ContentBase64 = Convert.ToBase64String("two"u8.ToArray()) }]
138 };
139 var p2Response = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", patch2Request);
140 var patch2 = await p2Response.Content.ReadFromJsonAsync<PatchResponse>();
141 Assert.NotNull(patch2);
142 Assert.Equal(1, patch2.OpCount);
143 }
144}
145
+ tests/Catena.Tests/MaturityTransitionTests.cs
1using Catena.Storage;
2using Catena.Shared.Dtos;
3using Catena.Shared.Models;
4
5namespace Catena.Tests;
6
7public class MaturityTransitionTests : IDisposable
8{
9 private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"catena-maturity-test-{Guid.NewGuid():N}");
10 private readonly PatchStore _patchStore;
11 private readonly ProjectStore _projectStore;
12
13 public MaturityTransitionTests()
14 {
15 _patchStore = new PatchStore(_tempDir);
16 _projectStore = new ProjectStore(_tempDir);
17 }
18
19 public void Dispose()
20 {
21 if (Directory.Exists(_tempDir))
22 Directory.Delete(_tempDir, true);
23 }
24
25 private CreatePatchRequest MakeRequest(Maturity maturity = Maturity.DraftSynced) => new()
26 {
27 Author = "testuser",
28 Description = "test",
29 Maturity = maturity,
30 Ops = [new FileOperation { Type = OpType.Insert, File = "a.txt", ContentBase64 = Convert.ToBase64String("hello"u8.ToArray()) }]
31 };
32
33 [Fact]
34 public async Task DraftSynced_To_Proposed_Succeeds()
35 {
36 var project = await _projectStore.CreateAsync("Test");
37 var patch = await _patchStore.CreateAsync(project.Id, MakeRequest());
38
39 var (updated, error) = await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Proposed);
40
41 Assert.Null(error);
42 Assert.NotNull(updated);
43 Assert.Equal(Maturity.Proposed, updated.Maturity);
44 }
45
46 [Fact]
47 public async Task Proposed_To_Accepted_Succeeds()
48 {
49 var project = await _projectStore.CreateAsync("Test");
50 var patch = await _patchStore.CreateAsync(project.Id, MakeRequest());
51 await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Proposed);
52
53 var (updated, error) = await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Accepted);
54
55 Assert.Null(error);
56 Assert.NotNull(updated);
57 Assert.Equal(Maturity.Accepted, updated.Maturity);
58 }
59
60 [Fact]
61 public async Task DraftSynced_To_Accepted_Fails()
62 {
63 var project = await _projectStore.CreateAsync("Test");
64 var patch = await _patchStore.CreateAsync(project.Id, MakeRequest());
65
66 var (updated, error) = await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Accepted);
67
68 Assert.NotNull(error);
69 Assert.NotNull(updated);
70 Assert.Equal(Maturity.DraftSynced, updated.Maturity); // unchanged
71 Assert.Contains("Invalid transition", error);
72 }
73
74 [Fact]
75 public async Task Accepted_To_Proposed_Fails()
76 {
77 var project = await _projectStore.CreateAsync("Test");
78 var patch = await _patchStore.CreateAsync(project.Id, MakeRequest());
79 await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Proposed);
80 await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Accepted);
81
82 var (updated, error) = await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.Proposed);
83
84 Assert.NotNull(error);
85 Assert.NotNull(updated);
86 Assert.Equal(Maturity.Accepted, updated.Maturity); // unchanged
87 Assert.Contains("Invalid transition", error);
88 }
89
90 [Fact]
91 public async Task SameMaturity_IsNoOp()
92 {
93 var project = await _projectStore.CreateAsync("Test");
94 var patch = await _patchStore.CreateAsync(project.Id, MakeRequest());
95
96 var (updated, error) = await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, Maturity.DraftSynced);
97
98 Assert.Null(error);
99 Assert.NotNull(updated);
100 Assert.Equal(Maturity.DraftSynced, updated.Maturity);
101 }
102
103 [Fact]
104 public async Task UnknownPatch_ReturnsError()
105 {
106 var (updated, error) = await _patchStore.UpdateMaturityAsync("fake", "fake", Maturity.Proposed);
107
108 Assert.NotNull(error);
109 Assert.Null(updated);
110 Assert.Contains("not found", error);
111 }
112
113 [Theory]
114 [InlineData(Maturity.DraftLocal, Maturity.DraftSynced)]
115 [InlineData(Maturity.DraftSynced, Maturity.DraftShared)]
116 [InlineData(Maturity.DraftShared, Maturity.Proposed)]
117 [InlineData(Maturity.DraftSynced, Maturity.Proposed)]
118 [InlineData(Maturity.Proposed, Maturity.Accepted)]
119 public async Task AllValidTransitions_Succeed(Maturity from, Maturity to)
120 {
121 var project = await _projectStore.CreateAsync("Test");
122 var patch = await _patchStore.CreateAsync(project.Id, MakeRequest(from));
123
124 var (updated, error) = await _patchStore.UpdateMaturityAsync(project.Id, patch.Id, to);
125
126 Assert.Null(error);
127 Assert.NotNull(updated);
128 Assert.Equal(to, updated.Maturity);
129 }
130}
131
+ tests/Catena.Tests/OffsetCalculatorTests.cs
1using Catena.Shared.Models;
2using Catena.Shared.Overlap;
3
4namespace Catena.Tests;
5
6public class OffsetCalculatorTests
7{
8 private static Operation Insert(long at, long length) => new()
9 {
10 Type = OpType.Insert,
11 File = "test.txt",
12 StartByte = at,
13 EndByte = at + length
14 };
15
16 private static Operation Modify(long start, long end) => new()
17 {
18 Type = OpType.Modify,
19 File = "test.txt",
20 StartByte = start,
21 EndByte = end
22 };
23
24 private static Operation Delete(long start, long end) => new()
25 {
26 Type = OpType.Delete,
27 File = "test.txt",
28 StartByte = start,
29 EndByte = end
30 };
31
32 private static Operation Rename() => new()
33 {
34 Type = OpType.Rename,
35 File = "old.txt",
36 NewPath = "new.txt"
37 };
38
39 // ===== INSERT BEFORE =====
40
41 [Fact]
42 public void Insert_BeforeRange_ShiftsRight()
43 {
44 var result = OffsetCalculator.Adjust(Insert(0, 10), targetStart: 20, targetEnd: 30);
45
46 Assert.NotNull(result);
47 Assert.Equal(30, result.Value.Start);
48 Assert.Equal(40, result.Value.End);
49 }
50
51 [Fact]
52 public void Insert_AtRangeStart_ShiftsRight()
53 {
54 var result = OffsetCalculator.Adjust(Insert(20, 5), targetStart: 20, targetEnd: 30);
55
56 Assert.NotNull(result);
57 Assert.Equal(25, result.Value.Start);
58 Assert.Equal(35, result.Value.End);
59 }
60
61 // ===== INSERT AFTER =====
62
63 [Fact]
64 public void Insert_AfterRange_NoShift()
65 {
66 var result = OffsetCalculator.Adjust(Insert(50, 10), targetStart: 20, targetEnd: 30);
67
68 Assert.NotNull(result);
69 Assert.Equal(20, result.Value.Start);
70 Assert.Equal(30, result.Value.End);
71 }
72
73 [Fact]
74 public void Insert_AtRangeEnd_NoShift()
75 {
76 var result = OffsetCalculator.Adjust(Insert(30, 5), targetStart: 20, targetEnd: 30);
77
78 Assert.NotNull(result);
79 Assert.Equal(20, result.Value.Start);
80 Assert.Equal(30, result.Value.End);
81 }
82
83 // ===== INSERT WITHIN =====
84
85 [Fact]
86 public void Insert_WithinRange_Conflict()
87 {
88 var result = OffsetCalculator.Adjust(Insert(25, 5), targetStart: 20, targetEnd: 30);
89
90 Assert.Null(result);
91 }
92
93 [Fact]
94 public void Insert_OneByteInsideRange_Conflict()
95 {
96 var result = OffsetCalculator.Adjust(Insert(21, 1), targetStart: 20, targetEnd: 30);
97
98 Assert.Null(result);
99 }
100
101 // ===== INSERT EDGE CASES =====
102
103 [Fact]
104 public void Insert_ZeroLength_NoChange()
105 {
106 var result = OffsetCalculator.Adjust(Insert(25, 0), targetStart: 20, targetEnd: 30);
107
108 Assert.NotNull(result);
109 Assert.Equal(20, result.Value.Start);
110 Assert.Equal(30, result.Value.End);
111 }
112
113 // ===== DELETE BEFORE =====
114
115 [Fact]
116 public void Delete_BeforeRange_ShiftsLeft()
117 {
118 var result = OffsetCalculator.Adjust(Delete(0, 10), targetStart: 20, targetEnd: 30);
119
120 Assert.NotNull(result);
121 Assert.Equal(10, result.Value.Start);
122 Assert.Equal(20, result.Value.End);
123 }
124
125 [Fact]
126 public void Delete_JustBeforeRange_ShiftsLeft()
127 {
128 var result = OffsetCalculator.Adjust(Delete(15, 20), targetStart: 20, targetEnd: 30);
129
130 Assert.NotNull(result);
131 Assert.Equal(15, result.Value.Start);
132 Assert.Equal(25, result.Value.End);
133 }
134
135 // ===== DELETE AFTER =====
136
137 [Fact]
138 public void Delete_AfterRange_NoShift()
139 {
140 var result = OffsetCalculator.Adjust(Delete(40, 50), targetStart: 20, targetEnd: 30);
141
142 Assert.NotNull(result);
143 Assert.Equal(20, result.Value.Start);
144 Assert.Equal(30, result.Value.End);
145 }
146
147 [Fact]
148 public void Delete_StartingAtRangeEnd_NoShift()
149 {
150 var result = OffsetCalculator.Adjust(Delete(30, 40), targetStart: 20, targetEnd: 30);
151
152 Assert.NotNull(result);
153 Assert.Equal(20, result.Value.Start);
154 Assert.Equal(30, result.Value.End);
155 }
156
157 // ===== DELETE OVERLAPPING =====
158
159 [Fact]
160 public void Delete_OverlappingRangeStart_Conflict()
161 {
162 var result = OffsetCalculator.Adjust(Delete(15, 25), targetStart: 20, targetEnd: 30);
163
164 Assert.Null(result);
165 }
166
167 [Fact]
168 public void Delete_OverlappingRangeEnd_Conflict()
169 {
170 var result = OffsetCalculator.Adjust(Delete(25, 35), targetStart: 20, targetEnd: 30);
171
172 Assert.Null(result);
173 }
174
175 [Fact]
176 public void Delete_EntirelyCoveringRange_Conflict()
177 {
178 var result = OffsetCalculator.Adjust(Delete(10, 40), targetStart: 20, targetEnd: 30);
179
180 Assert.Null(result);
181 }
182
183 [Fact]
184 public void Delete_WithinRange_Conflict()
185 {
186 var result = OffsetCalculator.Adjust(Delete(22, 28), targetStart: 20, targetEnd: 30);
187
188 Assert.Null(result);
189 }
190
191 [Fact]
192 public void Delete_ZeroLength_NoChange()
193 {
194 var result = OffsetCalculator.Adjust(Delete(25, 25), targetStart: 20, targetEnd: 30);
195
196 Assert.NotNull(result);
197 Assert.Equal(20, result.Value.Start);
198 Assert.Equal(30, result.Value.End);
199 }
200
201 // ===== MODIFY =====
202
203 [Fact]
204 public void Modify_BeforeRange_NoChange()
205 {
206 var result = OffsetCalculator.Adjust(Modify(0, 10), targetStart: 20, targetEnd: 30);
207
208 Assert.NotNull(result);
209 Assert.Equal(20, result.Value.Start);
210 Assert.Equal(30, result.Value.End);
211 }
212
213 [Fact]
214 public void Modify_AfterRange_NoChange()
215 {
216 var result = OffsetCalculator.Adjust(Modify(40, 50), targetStart: 20, targetEnd: 30);
217
218 Assert.NotNull(result);
219 Assert.Equal(20, result.Value.Start);
220 Assert.Equal(30, result.Value.End);
221 }
222
223 [Fact]
224 public void Modify_OverlappingRange_Conflict()
225 {
226 var result = OffsetCalculator.Adjust(Modify(15, 25), targetStart: 20, targetEnd: 30);
227
228 Assert.Null(result);
229 }
230
231 [Fact]
232 public void Modify_ExactRange_Conflict()
233 {
234 var result = OffsetCalculator.Adjust(Modify(20, 30), targetStart: 20, targetEnd: 30);
235
236 Assert.Null(result);
237 }
238
239 [Fact]
240 public void Modify_AdjacentBefore_NoConflict()
241 {
242 var result = OffsetCalculator.Adjust(Modify(10, 20), targetStart: 20, targetEnd: 30);
243
244 Assert.NotNull(result);
245 Assert.Equal(20, result.Value.Start);
246 Assert.Equal(30, result.Value.End);
247 }
248
249 [Fact]
250 public void Modify_AdjacentAfter_NoConflict()
251 {
252 var result = OffsetCalculator.Adjust(Modify(30, 40), targetStart: 20, targetEnd: 30);
253
254 Assert.NotNull(result);
255 }
256
257 // ===== RENAME/MOVE =====
258
259 [Fact]
260 public void Rename_DoesNotAffectRange()
261 {
262 var result = OffsetCalculator.Adjust(Rename(), targetStart: 20, targetEnd: 30);
263
264 Assert.NotNull(result);
265 Assert.Equal(20, result.Value.Start);
266 Assert.Equal(30, result.Value.End);
267 }
268}
269
+ tests/Catena.Tests/OverlapApiTests.cs
1using System.Net;
2using System.Net.Http.Json;
3using Catena.Shared.Dtos;
4using Catena.Shared.Models;
5using Catena.Shared.Overlap;
6using Microsoft.AspNetCore.Mvc.Testing;
7
8namespace Catena.Tests;
9
10public class OverlapApiTests
11{
12 private readonly HttpClient _client;
13
14 public OverlapApiTests()
15 {
16 var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
17 {
18 builder.UseSetting("Catena:AuthEnabled", "false");
19 builder.UseSetting("Catena:DataDir",
20 Path.Combine(Path.GetTempPath(), $"catena-overlap-test-{Guid.NewGuid():N}"));
21 });
22 _client = factory.CreateClient();
23 }
24
25 private async Task<string> CreateProjectAsync()
26 {
27 var response = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("Test"));
28 var project = await response.Content.ReadFromJsonAsync<ProjectResponse>();
29 return project!.Id;
30 }
31
32 private async Task<PatchResponse> CreateAndProposePatchAsync(string projectId, string file, string content, string author = "user")
33 {
34 var createReq = new CreatePatchRequest
35 {
36 Author = author,
37 Description = $"patch for {file}",
38 Maturity = Maturity.DraftSynced,
39 Ops = [new FileOperation { Type = OpType.Insert, File = file, ContentBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content)) }]
40 };
41 var createResp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", createReq);
42 var patch = await createResp.Content.ReadFromJsonAsync<PatchResponse>();
43
44 await _client.PutAsJsonAsync(
45 $"/projects/{projectId}/patches/{patch!.Id}/maturity",
46 new UpdateMaturityRequest(Maturity.Proposed));
47
48 return patch;
49 }
50
51 [Fact]
52 public async Task Overlaps_NoProposedPatches_ReturnsEmpty()
53 {
54 var projectId = await CreateProjectAsync();
55
56 var reports = await _client.GetFromJsonAsync<List<OverlapReportResponse>>($"/projects/{projectId}/overlaps");
57
58 Assert.NotNull(reports);
59 Assert.Empty(reports);
60 }
61
62 [Fact]
63 public async Task Overlaps_OneProposedPatch_ReturnsEmpty()
64 {
65 var projectId = await CreateProjectAsync();
66 await CreateAndProposePatchAsync(projectId, "only.txt", "hello");
67
68 var reports = await _client.GetFromJsonAsync<List<OverlapReportResponse>>($"/projects/{projectId}/overlaps");
69
70 Assert.NotNull(reports);
71 Assert.Empty(reports);
72 }
73
74 [Fact]
75 public async Task Overlaps_TwoPatches_DifferentFiles_NoConflicts()
76 {
77 var projectId = await CreateProjectAsync();
78 await CreateAndProposePatchAsync(projectId, "a.txt", "aaa");
79 await CreateAndProposePatchAsync(projectId, "b.txt", "bbb");
80
81 var reports = await _client.GetFromJsonAsync<List<OverlapReportResponse>>($"/projects/{projectId}/overlaps");
82
83 Assert.NotNull(reports);
84 Assert.Single(reports);
85 Assert.False(reports[0].HasConflicts);
86 Assert.All(reports[0].Items, i => Assert.Equal(OverlapKind.AutoApply, i.Kind));
87 }
88
89 [Fact]
90 public async Task Overlaps_TwoPatches_SameFile_DifferentContent_HasConflict()
91 {
92 var projectId = await CreateProjectAsync();
93 await CreateAndProposePatchAsync(projectId, "shared.txt", "version A");
94 await CreateAndProposePatchAsync(projectId, "shared.txt", "version B");
95
96 var reports = await _client.GetFromJsonAsync<List<OverlapReportResponse>>($"/projects/{projectId}/overlaps");
97
98 Assert.NotNull(reports);
99 Assert.Single(reports);
100 Assert.True(reports[0].HasConflicts);
101 }
102
103 [Fact]
104 public async Task Overlaps_TwoPatches_SameFile_SameContent_Deduplicated()
105 {
106 var projectId = await CreateProjectAsync();
107 await CreateAndProposePatchAsync(projectId, "same.txt", "identical");
108 await CreateAndProposePatchAsync(projectId, "same.txt", "identical");
109
110 var reports = await _client.GetFromJsonAsync<List<OverlapReportResponse>>($"/projects/{projectId}/overlaps");
111
112 Assert.NotNull(reports);
113 Assert.Single(reports);
114 Assert.False(reports[0].HasConflicts);
115 Assert.Contains(reports[0].Items, i => i.Kind == OverlapKind.Deduplicated);
116 }
117
118 [Fact]
119 public async Task Conflicts_ForSpecificPatch_ReturnsOnlyConflicts()
120 {
121 var projectId = await CreateProjectAsync();
122 var patchA = await CreateAndProposePatchAsync(projectId, "conflict.txt", "A");
123 await CreateAndProposePatchAsync(projectId, "conflict.txt", "B");
124
125 var conflicts = await _client.GetFromJsonAsync<List<OverlapReportResponse>>(
126 $"/projects/{projectId}/patches/{patchA.Id}/conflicts");
127
128 Assert.NotNull(conflicts);
129 Assert.Single(conflicts);
130 Assert.True(conflicts[0].HasConflicts);
131 Assert.All(conflicts[0].Items, i => Assert.Equal(OverlapKind.Conflict, i.Kind));
132 }
133
134 [Fact]
135 public async Task Conflicts_NoPatchesConflicting_ReturnsEmpty()
136 {
137 var projectId = await CreateProjectAsync();
138 var patchA = await CreateAndProposePatchAsync(projectId, "a.txt", "A");
139 await CreateAndProposePatchAsync(projectId, "b.txt", "B");
140
141 var conflicts = await _client.GetFromJsonAsync<List<OverlapReportResponse>>(
142 $"/projects/{projectId}/patches/{patchA.Id}/conflicts");
143
144 Assert.NotNull(conflicts);
145 Assert.Empty(conflicts);
146 }
147
148 [Fact]
149 public async Task Conflicts_UnknownPatch_ReturnsNotFound()
150 {
151 var projectId = await CreateProjectAsync();
152 var response = await _client.GetAsync($"/projects/{projectId}/patches/unknown/conflicts");
153 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
154 }
155
156 [Fact]
157 public async Task Overlaps_UnknownProject_ReturnsNotFound()
158 {
159 var response = await _client.GetAsync("/projects/unknown/overlaps");
160 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
161 }
162}
163
+ tests/Catena.Tests/OverlapDetectorTests.cs
1using Catena.Shared.Models;
2using Catena.Shared.Overlap;
3
4namespace Catena.Tests;
5
6public class OverlapDetectorTests
7{
8 private static Patch MakePatch(string id, params Operation[] ops) => new()
9 {
10 Id = id,
11 Author = "testuser",
12 ProjectId = "proj1",
13 Ops = [.. ops]
14 };
15
16 private static Operation FileInsert(string file, string content) => new()
17 {
18 Type = OpType.Insert,
19 File = file,
20 StartByte = 0,
21 EndByte = content.Length,
22 Content = System.Text.Encoding.UTF8.GetBytes(content)
23 };
24
25 private static Operation ByteInsert(string file, long start, long length, string content) => new()
26 {
27 Type = OpType.Insert,
28 File = file,
29 StartByte = start,
30 EndByte = start + length,
31 Content = System.Text.Encoding.UTF8.GetBytes(content)
32 };
33
34 private static Operation ByteModify(string file, long start, long end, string content) => new()
35 {
36 Type = OpType.Modify,
37 File = file,
38 StartByte = start,
39 EndByte = end,
40 Content = System.Text.Encoding.UTF8.GetBytes(content)
41 };
42
43 private static Operation FileDelete(string file) => new()
44 {
45 Type = OpType.Delete,
46 File = file,
47 StartByte = 0,
48 EndByte = 0
49 };
50
51 private static Operation FileRename(string from, string to) => new()
52 {
53 Type = OpType.Rename,
54 File = from,
55 NewPath = to
56 };
57
58 // ===== DIFFERENT FILES =====
59
60 [Fact]
61 public void DifferentFiles_AutoApply()
62 {
63 var a = MakePatch("a", FileInsert("foo.txt", "hello"));
64 var b = MakePatch("b", FileInsert("bar.txt", "world"));
65
66 var report = OverlapDetector.Detect(a, b);
67
68 Assert.Single(report.Results);
69 Assert.Equal(OverlapKind.AutoApply, report.Results[0].Kind);
70 Assert.False(report.HasConflicts);
71 }
72
73 [Fact]
74 public void MultipleOps_DifferentFiles_AllAutoApply()
75 {
76 var a = MakePatch("a", FileInsert("a.txt", "a"), FileInsert("b.txt", "b"));
77 var b = MakePatch("b", FileInsert("c.txt", "c"));
78
79 var report = OverlapDetector.Detect(a, b);
80
81 Assert.Equal(2, report.Results.Count);
82 Assert.All(report.Results, r => Assert.Equal(OverlapKind.AutoApply, r.Kind));
83 }
84
85 // ===== SAME FILE, DIFFERENT RANGES =====
86
87 [Fact]
88 public void SameFile_NonOverlappingRanges_AutoApply()
89 {
90 var a = MakePatch("a", ByteModify("test.txt", 0, 10, "aaaa"));
91 var b = MakePatch("b", ByteModify("test.txt", 20, 30, "bbbb"));
92
93 var report = OverlapDetector.Detect(a, b);
94
95 Assert.Single(report.Results);
96 Assert.Equal(OverlapKind.AutoApply, report.Results[0].Kind);
97 }
98
99 [Fact]
100 public void SameFile_AdjacentRanges_AutoApply()
101 {
102 var a = MakePatch("a", ByteModify("test.txt", 0, 10, "aaaa"));
103 var b = MakePatch("b", ByteModify("test.txt", 10, 20, "bbbb"));
104
105 var report = OverlapDetector.Detect(a, b);
106
107 Assert.Equal(OverlapKind.AutoApply, report.Results[0].Kind);
108 }
109
110 // ===== SAME FILE, OVERLAPPING RANGES, DIFFERENT CONTENT =====
111
112 [Fact]
113 public void SameFile_OverlappingRanges_DifferentContent_Conflict()
114 {
115 var a = MakePatch("a", ByteModify("test.txt", 0, 20, "aaaa"));
116 var b = MakePatch("b", ByteModify("test.txt", 10, 30, "bbbb"));
117
118 var report = OverlapDetector.Detect(a, b);
119
120 Assert.Single(report.Results);
121 Assert.Equal(OverlapKind.Conflict, report.Results[0].Kind);
122 Assert.True(report.HasConflicts);
123 }
124
125 [Fact]
126 public void SameFile_ExactSameRange_DifferentContent_Conflict()
127 {
128 var a = MakePatch("a", ByteModify("test.txt", 10, 20, "aaaa"));
129 var b = MakePatch("b", ByteModify("test.txt", 10, 20, "bbbb"));
130
131 var report = OverlapDetector.Detect(a, b);
132
133 Assert.Equal(OverlapKind.Conflict, report.Results[0].Kind);
134 }
135
136 // ===== SAME FILE, SAME RANGE, SAME CONTENT → DEDUPLICATE =====
137
138 [Fact]
139 public void SameFile_IdenticalModify_Deduplicated()
140 {
141 var a = MakePatch("a", ByteModify("test.txt", 10, 20, "same"));
142 var b = MakePatch("b", ByteModify("test.txt", 10, 20, "same"));
143
144 var report = OverlapDetector.Detect(a, b);
145
146 Assert.Equal(OverlapKind.Deduplicated, report.Results[0].Kind);
147 Assert.False(report.HasConflicts);
148 }
149
150 [Fact]
151 public void SameFile_IdenticalFileInsert_Deduplicated()
152 {
153 var a = MakePatch("a", FileInsert("new.txt", "content"));
154 var b = MakePatch("b", FileInsert("new.txt", "content"));
155
156 var report = OverlapDetector.Detect(a, b);
157
158 Assert.Equal(OverlapKind.Deduplicated, report.Results[0].Kind);
159 }
160
161 [Fact]
162 public void SameFile_DifferentFileInsert_Conflict()
163 {
164 var a = MakePatch("a", FileInsert("new.txt", "version A"));
165 var b = MakePatch("b", FileInsert("new.txt", "version B"));
166
167 var report = OverlapDetector.Detect(a, b);
168
169 Assert.Equal(OverlapKind.Conflict, report.Results[0].Kind);
170 }
171
172 // ===== DELETE SCENARIOS =====
173
174 [Fact]
175 public void BothDeleteSameFile_Deduplicated()
176 {
177 var a = MakePatch("a", FileDelete("old.txt"));
178 var b = MakePatch("b", FileDelete("old.txt"));
179
180 var report = OverlapDetector.Detect(a, b);
181
182 Assert.Equal(OverlapKind.Deduplicated, report.Results[0].Kind);
183 }
184
185 [Fact]
186 public void DeleteVsModify_SameFile_Conflict()
187 {
188 var a = MakePatch("a", FileDelete("test.txt"));
189 var b = MakePatch("b", ByteModify("test.txt", 0, 10, "edit"));
190
191 var report = OverlapDetector.Detect(a, b);
192
193 Assert.Equal(OverlapKind.Conflict, report.Results[0].Kind);
194 }
195
196 [Fact]
197 public void DeleteVsInsert_SameFile_Conflict()
198 {
199 var a = MakePatch("a", FileDelete("test.txt"));
200 var b = MakePatch("b", FileInsert("test.txt", "new content"));
201
202 var report = OverlapDetector.Detect(a, b);
203
204 Assert.Equal(OverlapKind.Conflict, report.Results[0].Kind);
205 }
206
207 // ===== RENAME/MOVE SCENARIOS =====
208
209 [Fact]
210 public void RenameVsModify_SameFile_Conflict()
211 {
212 var a = MakePatch("a", FileRename("test.txt", "renamed.txt"));
213 var b = MakePatch("b", ByteModify("test.txt", 0, 10, "edit"));
214
215 var report = OverlapDetector.Detect(a, b);
216
217 Assert.Equal(OverlapKind.Conflict, report.Results[0].Kind);
218 }
219
220 [Fact]
221 public void RenameVsDelete_SameFile_Conflict()
222 {
223 var a = MakePatch("a", FileRename("test.txt", "renamed.txt"));
224 var b = MakePatch("b", FileDelete("test.txt"));
225
226 var report = OverlapDetector.Detect(a, b);
227
228 Assert.Equal(OverlapKind.Conflict, report.Results[0].Kind);
229 }
230
231 [Fact]
232 public void Rename_DifferentFile_AutoApply()
233 {
234 var a = MakePatch("a", FileRename("one.txt", "two.txt"));
235 var b = MakePatch("b", ByteModify("other.txt", 0, 10, "edit"));
236
237 var report = OverlapDetector.Detect(a, b);
238
239 Assert.Equal(OverlapKind.AutoApply, report.Results[0].Kind);
240 }
241
242 // ===== MIXED OPS IN PATCHES =====
243
244 [Fact]
245 public void MixedOps_SomeConflictSomeNot()
246 {
247 var a = MakePatch("a",
248 ByteModify("shared.txt", 0, 10, "A edit"),
249 FileInsert("only-a.txt", "a content"));
250
251 var b = MakePatch("b",
252 ByteModify("shared.txt", 5, 15, "B edit"),
253 FileInsert("only-b.txt", "b content"));
254
255 var report = OverlapDetector.Detect(a, b);
256
257 // 2x2 = 4 comparisons
258 Assert.Equal(4, report.Results.Count);
259 Assert.True(report.HasConflicts);
260
261 // shared.txt vs shared.txt = conflict (overlapping ranges)
262 var sharedVsShared = report.Results.First(r => r.FileA == "shared.txt" && r.FileB == "shared.txt");
263 Assert.Equal(OverlapKind.Conflict, sharedVsShared.Kind);
264
265 // Different files = auto-apply
266 var aVsB = report.Results.First(r => r.FileA == "only-a.txt" && r.FileB == "only-b.txt");
267 Assert.Equal(OverlapKind.AutoApply, aVsB.Kind);
268 }
269
270 // ===== PATH NORMALIZATION =====
271
272 [Fact]
273 public void BackslashPaths_AreNormalized()
274 {
275 var a = MakePatch("a", ByteModify("src\\test.txt", 0, 10, "A"));
276 var b = MakePatch("b", ByteModify("src/test.txt", 5, 15, "B"));
277
278 var report = OverlapDetector.Detect(a, b);
279
280 Assert.Equal(OverlapKind.Conflict, report.Results[0].Kind);
281 }
282
283 // ===== REPORT PROPERTIES =====
284
285 [Fact]
286 public void Report_PatchIds_AreSet()
287 {
288 var a = MakePatch("patch-a", FileInsert("a.txt", "a"));
289 var b = MakePatch("patch-b", FileInsert("b.txt", "b"));
290
291 var report = OverlapDetector.Detect(a, b);
292
293 Assert.Equal("patch-a", report.PatchIdA);
294 Assert.Equal("patch-b", report.PatchIdB);
295 }
296
297 [Fact]
298 public void Report_OpIndices_AreCorrect()
299 {
300 var a = MakePatch("a",
301 FileInsert("first.txt", "1"),
302 FileInsert("second.txt", "2"));
303 var b = MakePatch("b", FileInsert("second.txt", "2"));
304
305 var report = OverlapDetector.Detect(a, b);
306
307 var dedup = report.Results.First(r => r.Kind == OverlapKind.Deduplicated);
308 Assert.Equal(1, dedup.OpIndexA); // second op in patch A
309 Assert.Equal(0, dedup.OpIndexB); // first op in patch B
310 }
311
312 // ===== EMPTY PATCHES =====
313
314 [Fact]
315 public void EmptyPatch_NoResults()
316 {
317 var a = MakePatch("a");
318 var b = MakePatch("b", FileInsert("test.txt", "hello"));
319
320 var report = OverlapDetector.Detect(a, b);
321
322 Assert.Empty(report.Results);
323 Assert.False(report.HasConflicts);
324 }
325}
326
+ tests/Catena.Tests/PatchApiTests.cs
1using System.Net;
2using System.Net.Http.Json;
3using Catena.Shared.Dtos;
4using Catena.Shared.Models;
5using Microsoft.AspNetCore.Mvc.Testing;
6
7namespace Catena.Tests;
8
9public class PatchApiTests
10{
11 private readonly HttpClient _client;
12
13 public PatchApiTests()
14 {
15 var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
16 {
17 builder.UseSetting("Catena:AuthEnabled", "false");
18 builder.UseSetting("Catena:DataDir",
19 Path.Combine(Path.GetTempPath(), $"catena-patchapi-test-{Guid.NewGuid():N}"));
20 });
21 _client = factory.CreateClient();
22 }
23
24 private async Task<string> CreateProjectAsync()
25 {
26 var response = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("TestProject"));
27 var project = await response.Content.ReadFromJsonAsync<ProjectResponse>();
28 return project!.Id;
29 }
30
31 private static CreatePatchRequest MakePatchRequest() => new()
32 {
33 Author = "testuser",
34 Description = "test patch",
35 Maturity = Maturity.DraftSynced,
36 Ops =
37 [
38 new FileOperation
39 {
40 Type = OpType.Insert,
41 File = "hello.txt",
42 ContentBase64 = Convert.ToBase64String("hello"u8.ToArray())
43 }
44 ]
45 };
46
47 [Fact]
48 public async Task PostPatch_ReturnsCreated()
49 {
50 var projectId = await CreateProjectAsync();
51
52 var response = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", MakePatchRequest());
53
54 Assert.Equal(HttpStatusCode.Created, response.StatusCode);
55 var patch = await response.Content.ReadFromJsonAsync<PatchResponse>();
56 Assert.NotNull(patch);
57 Assert.Equal("testuser", patch.Author);
58 Assert.Equal(1, patch.OpCount);
59 }
60
61 [Fact]
62 public async Task PostPatch_NoOps_ReturnsBadRequest()
63 {
64 var projectId = await CreateProjectAsync();
65 var request = new CreatePatchRequest { Author = "user", Description = "empty", Ops = [] };
66
67 var response = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", request);
68
69 Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
70 }
71
72 [Fact]
73 public async Task PostPatch_UnknownProject_ReturnsNotFound()
74 {
75 var response = await _client.PostAsJsonAsync("/projects/doesnotexist/patches", MakePatchRequest());
76
77 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
78 }
79
80 [Fact]
81 public async Task GetPatches_ReturnsList()
82 {
83 var projectId = await CreateProjectAsync();
84 await _client.PostAsJsonAsync($"/projects/{projectId}/patches", MakePatchRequest());
85
86 var patches = await _client.GetFromJsonAsync<List<PatchResponse>>($"/projects/{projectId}/patches");
87
88 Assert.NotNull(patches);
89 Assert.Single(patches);
90 }
91
92 [Fact]
93 public async Task GetPatchById_ReturnsCorrectPatch()
94 {
95 var projectId = await CreateProjectAsync();
96 var createResponse = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", MakePatchRequest());
97 var created = await createResponse.Content.ReadFromJsonAsync<PatchResponse>();
98
99 var patch = await _client.GetFromJsonAsync<PatchResponse>($"/projects/{projectId}/patches/{created!.Id}");
100
101 Assert.NotNull(patch);
102 Assert.Equal(created.Id, patch.Id);
103 }
104
105 [Fact]
106 public async Task GetPatchById_Unknown_ReturnsNotFound()
107 {
108 var projectId = await CreateProjectAsync();
109 var response = await _client.GetAsync($"/projects/{projectId}/patches/doesnotexist");
110 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
111 }
112}
113
+ tests/Catena.Tests/PatchStoreTests.cs
1using Catena.Storage;
2using Catena.Shared.Dtos;
3using Catena.Shared.Models;
4
5namespace Catena.Tests;
6
7public class PatchStoreTests : IDisposable
8{
9 private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"catena-patch-test-{Guid.NewGuid():N}");
10 private readonly PatchStore _store;
11 private readonly ProjectStore _projectStore;
12
13 public PatchStoreTests()
14 {
15 _store = new PatchStore(_tempDir);
16 _projectStore = new ProjectStore(_tempDir);
17 }
18
19 public void Dispose()
20 {
21 if (Directory.Exists(_tempDir))
22 Directory.Delete(_tempDir, true);
23 }
24
25 private CreatePatchRequest MakeRequest(string author = "testuser", string description = "test patch") => new()
26 {
27 Author = author,
28 Description = description,
29 Maturity = Maturity.DraftSynced,
30 Ops =
31 [
32 new FileOperation
33 {
34 Type = OpType.Insert,
35 File = "hello.txt",
36 ContentBase64 = Convert.ToBase64String("hello world"u8.ToArray())
37 }
38 ]
39 };
40
41 [Fact]
42 public async Task CreateAsync_ReturnsPatchWithId()
43 {
44 var project = await _projectStore.CreateAsync("Test");
45 var patch = await _store.CreateAsync(project.Id, MakeRequest());
46
47 Assert.NotNull(patch.Id);
48 Assert.Equal(16, patch.Id.Length);
49 Assert.Equal("testuser", patch.Author);
50 Assert.Equal(project.Id, patch.ProjectId);
51 Assert.Equal(Maturity.DraftSynced, patch.Maturity);
52 Assert.Single(patch.Ops);
53 }
54
55 [Fact]
56 public async Task CreateAsync_StoresBlob()
57 {
58 var project = await _projectStore.CreateAsync("Test");
59 var patch = await _store.CreateAsync(project.Id, MakeRequest());
60
61 var blob = await _store.GetBlobAsync(project.Id, patch.Id, 0);
62 Assert.NotNull(blob);
63 Assert.Equal("hello world", System.Text.Encoding.UTF8.GetString(blob));
64 }
65
66 [Fact]
67 public async Task GetAsync_ReturnsStoredPatch()
68 {
69 var project = await _projectStore.CreateAsync("Test");
70 var created = await _store.CreateAsync(project.Id, MakeRequest());
71
72 var loaded = await _store.GetAsync(project.Id, created.Id);
73
74 Assert.NotNull(loaded);
75 Assert.Equal(created.Id, loaded.Id);
76 Assert.Equal(created.Author, loaded.Author);
77 }
78
79 [Fact]
80 public async Task GetAsync_ReturnsNullForUnknown()
81 {
82 var result = await _store.GetAsync("fakeproject", "fakepatch");
83 Assert.Null(result);
84 }
85
86 [Fact]
87 public async Task ListAsync_ReturnsAllPatchesForProject()
88 {
89 var project = await _projectStore.CreateAsync("Test");
90 await _store.CreateAsync(project.Id, MakeRequest(description: "first"));
91 await _store.CreateAsync(project.Id, MakeRequest(description: "second"));
92
93 var patches = await _store.ListAsync(project.Id);
94
95 Assert.Equal(2, patches.Count);
96 }
97
98 [Fact]
99 public async Task ListAsync_FiltersByMaturity()
100 {
101 var project = await _projectStore.CreateAsync("Test");
102 await _store.CreateAsync(project.Id, MakeRequest());
103
104 var request2 = new CreatePatchRequest
105 {
106 Author = "testuser",
107 Description = "proposed",
108 Maturity = Maturity.Proposed,
109 Ops = [new FileOperation { Type = OpType.Insert, File = "b.txt", ContentBase64 = Convert.ToBase64String("x"u8.ToArray()) }]
110 };
111 await _store.CreateAsync(project.Id, request2);
112
113 var synced = await _store.ListAsync(project.Id, maturityFilter: Maturity.DraftSynced);
114 var proposed = await _store.ListAsync(project.Id, maturityFilter: Maturity.Proposed);
115
116 Assert.Single(synced);
117 Assert.Single(proposed);
118 }
119
120 [Fact]
121 public async Task ListAsync_FiltersByAuthor()
122 {
123 var project = await _projectStore.CreateAsync("Test");
124 await _store.CreateAsync(project.Id, MakeRequest(author: "alice"));
125 await _store.CreateAsync(project.Id, MakeRequest(author: "bob"));
126
127 var alicePatches = await _store.ListAsync(project.Id, authorFilter: "alice");
128
129 Assert.Single(alicePatches);
130 Assert.Equal("alice", alicePatches[0].Author);
131 }
132
133 [Fact]
134 public async Task CreateAsync_DeleteOp_NoBlobStored()
135 {
136 var project = await _projectStore.CreateAsync("Test");
137 var request = new CreatePatchRequest
138 {
139 Author = "testuser",
140 Description = "delete",
141 Ops = [new FileOperation { Type = OpType.Delete, File = "gone.txt" }]
142 };
143
144 var patch = await _store.CreateAsync(project.Id, request);
145 var blob = await _store.GetBlobAsync(project.Id, patch.Id, 0);
146
147 Assert.Null(blob);
148 }
149}
150
+ tests/Catena.Tests/ProjectApiTests.cs
1using System.Net;
2using System.Net.Http.Json;
3using Catena.Shared.Dtos;
4using Microsoft.AspNetCore.Mvc.Testing;
5
6namespace Catena.Tests;
7
8public class ProjectApiTests
9{
10 private readonly HttpClient _client;
11
12 public ProjectApiTests()
13 {
14 var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
15 {
16 builder.UseSetting("Catena:AuthEnabled", "false");
17 builder.UseSetting("Catena:DataDir",
18 Path.Combine(Path.GetTempPath(), $"catena-api-test-{Guid.NewGuid():N}"));
19 });
20 _client = factory.CreateClient();
21 }
22
23 [Fact]
24 public async Task PostProject_ReturnsCreated()
25 {
26 var response = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("MyProject", true));
27
28 Assert.Equal(HttpStatusCode.Created, response.StatusCode);
29
30 var project = await response.Content.ReadFromJsonAsync<ProjectResponse>();
31 Assert.NotNull(project);
32 Assert.Equal("MyProject", project.Name);
33 Assert.NotEmpty(project.Id);
34 }
35
36 [Fact]
37 public async Task PostProject_EmptyName_ReturnsBadRequest()
38 {
39 var response = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest(""));
40
41 Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
42 }
43
44 [Fact]
45 public async Task GetProjects_ReturnsListWithCreatedProject()
46 {
47 await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("Listed", true));
48
49 var projects = await _client.GetFromJsonAsync<List<ProjectResponse>>("/projects");
50
51 Assert.NotNull(projects);
52 Assert.Contains(projects, p => p.Name == "Listed");
53 }
54
55 [Fact]
56 public async Task GetProjectById_ReturnsCorrectProject()
57 {
58 var createResponse = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("ById", true));
59 var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
60
61 var project = await _client.GetFromJsonAsync<ProjectResponse>($"/projects/{created!.Id}");
62
63 Assert.NotNull(project);
64 Assert.Equal("ById", project.Name);
65 }
66
67 [Fact]
68 public async Task GetProjectById_UnknownId_ReturnsNotFound()
69 {
70 var response = await _client.GetAsync("/projects/doesnotexist");
71 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
72 }
73}
74
+ tests/Catena.Tests/ProjectStoreTests.cs
1using Catena.Storage;
2
3namespace Catena.Tests;
4
5public class ProjectStoreTests : IDisposable
6{
7 private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"catena-test-{Guid.NewGuid():N}");
8 private readonly ProjectStore _store;
9
10 public ProjectStoreTests()
11 {
12 _store = new ProjectStore(_tempDir);
13 }
14
15 public void Dispose()
16 {
17 if (Directory.Exists(_tempDir))
18 Directory.Delete(_tempDir, recursive: true);
19 }
20
21 [Fact]
22 public async Task CreateAsync_ReturnsProjectWithIdAndName()
23 {
24 var project = await _store.CreateAsync("TestProject");
25
26 Assert.NotNull(project.Id);
27 Assert.Equal(12, project.Id.Length);
28 Assert.Equal("TestProject", project.Name);
29 }
30
31 [Fact]
32 public async Task CreateAsync_CreatesDirectoryStructure()
33 {
34 var project = await _store.CreateAsync("TestProject");
35
36 Assert.True(Directory.Exists(Path.Combine(_tempDir, project.Id)));
37 Assert.True(Directory.Exists(Path.Combine(_tempDir, project.Id, "patches")));
38 Assert.True(Directory.Exists(Path.Combine(_tempDir, project.Id, "snapshots")));
39 }
40
41 [Fact]
42 public async Task GetAsync_ReturnsCreatedProject()
43 {
44 var created = await _store.CreateAsync("TestProject");
45 var loaded = await _store.GetAsync(created.Id);
46
47 Assert.NotNull(loaded);
48 Assert.Equal(created.Id, loaded.Id);
49 Assert.Equal(created.Name, loaded.Name);
50 }
51
52 [Fact]
53 public async Task GetAsync_ReturnsNullForUnknownId()
54 {
55 var result = await _store.GetAsync("doesnotexist");
56 Assert.Null(result);
57 }
58
59 [Fact]
60 public async Task ListAsync_ReturnsAllProjects()
61 {
62 await _store.CreateAsync("Alpha");
63 await _store.CreateAsync("Beta");
64
65 var projects = await _store.ListAsync();
66
67 Assert.Equal(2, projects.Count);
68 Assert.Contains(projects, p => p.Name == "Alpha");
69 Assert.Contains(projects, p => p.Name == "Beta");
70 }
71}
72
+ tests/Catena.Tests/ReleaseApiTests.cs
1using System.Net;
2using System.Net.Http.Json;
3using Catena.Shared.Dtos;
4using Catena.Shared.Models;
5using Microsoft.AspNetCore.Mvc.Testing;
6
7namespace Catena.Tests;
8
9public class ReleaseApiTests
10{
11 private readonly HttpClient _client;
12
13 public ReleaseApiTests()
14 {
15 var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
16 {
17 builder.UseSetting("Catena:AuthEnabled", "false");
18 builder.UseSetting("Catena:DataDir",
19 Path.Combine(Path.GetTempPath(), $"catena-release-test-{Guid.NewGuid():N}"));
20 });
21 _client = factory.CreateClient();
22 }
23
24 private async Task<string> CreateProjectWithFilesAsync()
25 {
26 var resp = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("Test"));
27 var project = await resp.Content.ReadFromJsonAsync<ProjectResponse>();
28 var projectId = project!.Id;
29
30 // Create and accept a patch so trunk has files
31 var patchReq = new CreatePatchRequest
32 {
33 Author = "user",
34 Description = "add files",
35 Maturity = Maturity.DraftSynced,
36 Ops =
37 [
38 new FileOperation { Type = OpType.Insert, File = "app.cs", ContentBase64 = Convert.ToBase64String("code"u8.ToArray()) },
39 new FileOperation { Type = OpType.Insert, File = "readme.md", ContentBase64 = Convert.ToBase64String("# Hello"u8.ToArray()) }
40 ]
41 };
42 var patchResp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", patchReq);
43 var patch = await patchResp.Content.ReadFromJsonAsync<PatchResponse>();
44
45 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch!.Id}/maturity", new UpdateMaturityRequest(Maturity.Proposed));
46 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch.Id}/maturity", new UpdateMaturityRequest(Maturity.Accepted));
47
48 return projectId;
49 }
50
51 [Fact]
52 public async Task CreateRelease_ReturnsCreated()
53 {
54 var projectId = await CreateProjectWithFilesAsync();
55
56 var response = await _client.PostAsJsonAsync($"/projects/{projectId}/releases", new CreateReleaseRequest("v1.0.0"));
57
58 Assert.Equal(HttpStatusCode.Created, response.StatusCode);
59 var release = await response.Content.ReadFromJsonAsync<ReleaseResponse>();
60 Assert.NotNull(release);
61 Assert.Equal("v1.0.0", release.Version);
62 Assert.Equal(2, release.FileCount);
63 Assert.NotEmpty(release.TrunkHash);
64 }
65
66 [Fact]
67 public async Task CreateRelease_DuplicateVersion_ReturnsConflict()
68 {
69 var projectId = await CreateProjectWithFilesAsync();
70
71 await _client.PostAsJsonAsync($"/projects/{projectId}/releases", new CreateReleaseRequest("v1.0.0"));
72 var response = await _client.PostAsJsonAsync($"/projects/{projectId}/releases", new CreateReleaseRequest("v1.0.0"));
73
74 Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
75 }
76
77 [Fact]
78 public async Task CreateRelease_EmptyVersion_ReturnsBadRequest()
79 {
80 var projectId = await CreateProjectWithFilesAsync();
81
82 var response = await _client.PostAsJsonAsync($"/projects/{projectId}/releases", new CreateReleaseRequest(""));
83
84 Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
85 }
86
87 [Fact]
88 public async Task ListReleases_ReturnsAll()
89 {
90 var projectId = await CreateProjectWithFilesAsync();
91
92 await _client.PostAsJsonAsync($"/projects/{projectId}/releases", new CreateReleaseRequest("v1.0.0"));
93 await _client.PostAsJsonAsync($"/projects/{projectId}/releases", new CreateReleaseRequest("v1.1.0"));
94
95 var releases = await _client.GetFromJsonAsync<List<ReleaseResponse>>($"/projects/{projectId}/releases");
96
97 Assert.NotNull(releases);
98 Assert.Equal(2, releases.Count);
99 }
100
101 [Fact]
102 public async Task GetRelease_ReturnsCorrectVersion()
103 {
104 var projectId = await CreateProjectWithFilesAsync();
105 await _client.PostAsJsonAsync($"/projects/{projectId}/releases", new CreateReleaseRequest("v1.0.0"));
106
107 var release = await _client.GetFromJsonAsync<ReleaseResponse>($"/projects/{projectId}/releases/v1.0.0");
108
109 Assert.NotNull(release);
110 Assert.Equal("v1.0.0", release.Version);
111 Assert.Equal(2, release.FileCount);
112 }
113
114 [Fact]
115 public async Task GetRelease_UnknownVersion_ReturnsNotFound()
116 {
117 var projectId = await CreateProjectWithFilesAsync();
118 var response = await _client.GetAsync($"/projects/{projectId}/releases/v99.0.0");
119 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
120 }
121
122 [Fact]
123 public async Task Release_SnapshotIsImmutable_TrunkChangesDoNotAffect()
124 {
125 var projectId = await CreateProjectWithFilesAsync();
126
127 // Create release with 2 files
128 await _client.PostAsJsonAsync($"/projects/{projectId}/releases", new CreateReleaseRequest("v1.0.0"));
129
130 // Add another file to trunk
131 var patchReq = new CreatePatchRequest
132 {
133 Author = "user",
134 Description = "add more",
135 Maturity = Maturity.DraftSynced,
136 Ops = [new FileOperation { Type = OpType.Insert, File = "extra.txt", ContentBase64 = Convert.ToBase64String("extra"u8.ToArray()) }]
137 };
138 var patchResp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", patchReq);
139 var patch = await patchResp.Content.ReadFromJsonAsync<PatchResponse>();
140 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch!.Id}/maturity", new UpdateMaturityRequest(Maturity.Proposed));
141 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch.Id}/maturity", new UpdateMaturityRequest(Maturity.Accepted));
142
143 // Trunk now has 3 files
144 var trunk = await _client.GetFromJsonAsync<TrunkStateResponse>($"/projects/{projectId}/trunk");
145 Assert.Equal(3, trunk!.Files.Count);
146
147 // Release still has 2
148 var release = await _client.GetFromJsonAsync<ReleaseResponse>($"/projects/{projectId}/releases/v1.0.0");
149 Assert.Equal(2, release!.FileCount);
150 }
151
152 [Fact]
153 public async Task Release_UnknownProject_ReturnsNotFound()
154 {
155 var response = await _client.PostAsJsonAsync("/projects/unknown/releases", new CreateReleaseRequest("v1.0.0"));
156 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
157 }
158}
159
+ tests/Catena.Tests/ReviewStoreTests.cs
1using Catena.Shared.Models;
2using Catena.Storage;
3
4namespace Catena.Tests;
5
6public class ReviewStoreTests : IDisposable
7{
8 private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"catena-review-test-{Guid.NewGuid():N}");
9 private readonly ReviewStore _store;
10 private readonly ProjectStore _projectStore;
11 private readonly PatchStore _patchStore;
12
13 public ReviewStoreTests()
14 {
15 _store = new ReviewStore(_tempDir);
16 _projectStore = new ProjectStore(_tempDir);
17 _patchStore = new PatchStore(_tempDir);
18 }
19
20 public void Dispose()
21 {
22 if (Directory.Exists(_tempDir))
23 Directory.Delete(_tempDir, true);
24 }
25
26 private async Task<(string ProjectId, string PatchId)> SetupPatch()
27 {
28 var project = await _projectStore.CreateAsync("Test");
29 var patch = await _patchStore.CreateAsync(project.Id, new Catena.Shared.Dtos.CreatePatchRequest
30 {
31 Author = "user",
32 Description = "test",
33 Ops = [new Catena.Shared.Dtos.FileOperation { Type = OpType.Insert, File = "a.txt", ContentBase64 = Convert.ToBase64String("x"u8.ToArray()) }]
34 });
35 return (project.Id, patch.Id);
36 }
37
38 [Fact]
39 public async Task CreateAsync_ReturnsReview()
40 {
41 var (pid, patchId) = await SetupPatch();
42 var review = await _store.CreateAsync(pid, patchId, "reviewer1", "Alice");
43
44 Assert.NotNull(review.Id);
45 Assert.Equal(patchId, review.PatchId);
46 Assert.Equal("Alice", review.ReviewerName);
47 Assert.Equal(ReviewStatus.Pending, review.Status);
48 }
49
50 [Fact]
51 public async Task UpdateAsync_ChangesStatus()
52 {
53 var (pid, patchId) = await SetupPatch();
54 var review = await _store.CreateAsync(pid, patchId, "r1", "Alice");
55
56 var updated = await _store.UpdateAsync(pid, patchId, review.Id, ReviewStatus.Approved, "LGTM");
57
58 Assert.NotNull(updated);
59 Assert.Equal(ReviewStatus.Approved, updated.Status);
60 Assert.Equal("LGTM", updated.Comment);
61 Assert.NotNull(updated.UpdatedAt);
62 }
63
64 [Fact]
65 public async Task UpdateAsync_UnknownId_ReturnsNull()
66 {
67 var (pid, patchId) = await SetupPatch();
68 var result = await _store.UpdateAsync(pid, patchId, "unknown", ReviewStatus.Approved, "");
69 Assert.Null(result);
70 }
71
72 [Fact]
73 public async Task ListForPatchAsync_ReturnsAll()
74 {
75 var (pid, patchId) = await SetupPatch();
76 await _store.CreateAsync(pid, patchId, "r1", "Alice");
77 await _store.CreateAsync(pid, patchId, "r2", "Bob");
78
79 var reviews = await _store.ListForPatchAsync(pid, patchId);
80 Assert.Equal(2, reviews.Count);
81 }
82
83 [Fact]
84 public async Task ListForPatchAsync_NoPatch_ReturnsEmpty()
85 {
86 var reviews = await _store.ListForPatchAsync("fake", "fake");
87 Assert.Empty(reviews);
88 }
89
90}
91
+ tests/Catena.Tests/SyncApiTests.cs
1using System.Net;
2using System.Net.Http.Json;
3using System.Text.Json;
4using Catena.Shared.Dtos;
5using Catena.Shared.Models;
6using Microsoft.AspNetCore.Mvc.Testing;
7
8namespace Catena.Tests;
9
10public class SyncApiTests
11{
12 private readonly HttpClient _client;
13
14 public SyncApiTests()
15 {
16 var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
17 {
18 builder.UseSetting("Catena:AuthEnabled", "false");
19 builder.UseSetting("Catena:DataDir",
20 Path.Combine(Path.GetTempPath(), $"catena-sync-test-{Guid.NewGuid():N}"));
21 });
22 _client = factory.CreateClient();
23 }
24
25 private async Task<(string ProjectId, string PatchId)> SetupProjectWithFileAsync(string file = "hello.txt", string content = "hello world")
26 {
27 var projResp = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("Test"));
28 var project = await projResp.Content.ReadFromJsonAsync<ProjectResponse>();
29 var projectId = project!.Id;
30
31 var patchReq = new CreatePatchRequest
32 {
33 Author = "user",
34 Description = $"add {file}",
35 Maturity = Maturity.DraftSynced,
36 Ops = [new FileOperation { Type = OpType.Insert, File = file, ContentBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content)) }]
37 };
38 var patchResp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", patchReq);
39 var patch = await patchResp.Content.ReadFromJsonAsync<PatchResponse>();
40
41 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch!.Id}/maturity", new UpdateMaturityRequest(Maturity.Proposed));
42 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch.Id}/maturity", new UpdateMaturityRequest(Maturity.Accepted));
43
44 return (projectId, patch.Id);
45 }
46
47 // ===== SINGLE FILE DOWNLOAD =====
48
49 [Fact]
50 public async Task GetTrunkFile_ReturnsContent()
51 {
52 var (projectId, _) = await SetupProjectWithFileAsync("hello.txt", "hello world");
53
54 var response = await _client.GetAsync($"/projects/{projectId}/trunk/files/hello.txt");
55
56 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
57 var content = await response.Content.ReadAsByteArrayAsync();
58 Assert.Equal("hello world", System.Text.Encoding.UTF8.GetString(content));
59 }
60
61 [Fact]
62 public async Task GetTrunkFile_UnknownFile_ReturnsNotFound()
63 {
64 var (projectId, _) = await SetupProjectWithFileAsync();
65
66 var response = await _client.GetAsync($"/projects/{projectId}/trunk/files/nope.txt");
67
68 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
69 }
70
71 [Fact]
72 public async Task GetTrunkFile_SubdirectoryPath()
73 {
74 var (projectId, _) = await SetupProjectWithFileAsync("src/app.cs", "code");
75
76 var response = await _client.GetAsync($"/projects/{projectId}/trunk/files/src/app.cs");
77
78 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
79 var content = await response.Content.ReadAsByteArrayAsync();
80 Assert.Equal("code", System.Text.Encoding.UTF8.GetString(content));
81 }
82
83 // ===== BULK DOWNLOAD =====
84
85 [Fact]
86 public async Task BulkDownload_ReturnsNdjson()
87 {
88 var (projectId, _) = await SetupProjectWithFileAsync("a.txt", "aaa");
89
90 // Add second file
91 var req2 = new CreatePatchRequest
92 {
93 Author = "user",
94 Description = "add b",
95 Maturity = Maturity.DraftSynced,
96 Ops = [new FileOperation { Type = OpType.Insert, File = "b.txt", ContentBase64 = Convert.ToBase64String("bbb"u8.ToArray()) }]
97 };
98 var p2 = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", req2);
99 var patch2 = await p2.Content.ReadFromJsonAsync<PatchResponse>();
100 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch2!.Id}/maturity", new UpdateMaturityRequest(Maturity.Proposed));
101 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{patch2.Id}/maturity", new UpdateMaturityRequest(Maturity.Accepted));
102
103 var response = await _client.PostAsJsonAsync($"/projects/{projectId}/trunk/download",
104 new BulkDownloadRequest(["a.txt", "b.txt"]));
105
106 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
107
108 var body = await response.Content.ReadAsStringAsync();
109 var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
110 Assert.Equal(2, lines.Length);
111
112 var file1 = JsonSerializer.Deserialize<FileContentResponse>(lines[0]);
113 var file2 = JsonSerializer.Deserialize<FileContentResponse>(lines[1]);
114 Assert.NotNull(file1);
115 Assert.NotNull(file2);
116
117 var contents = new[] { file1, file2 }.ToDictionary(f => f.Path, f => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(f.ContentBase64)));
118 Assert.Equal("aaa", contents["a.txt"]);
119 Assert.Equal("bbb", contents["b.txt"]);
120 }
121
122 [Fact]
123 public async Task BulkDownload_SkipsUnknownFiles()
124 {
125 var (projectId, _) = await SetupProjectWithFileAsync("exists.txt", "yes");
126
127 var response = await _client.PostAsJsonAsync($"/projects/{projectId}/trunk/download",
128 new BulkDownloadRequest(["exists.txt", "nope.txt"]));
129
130 var body = await response.Content.ReadAsStringAsync();
131 var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
132 Assert.Single(lines);
133 }
134
135 // ===== PATCH BLOB DOWNLOAD =====
136
137 [Fact]
138 public async Task GetPatchBlob_ReturnsContent()
139 {
140 var (projectId, patchId) = await SetupProjectWithFileAsync("file.txt", "content");
141
142 var response = await _client.GetAsync($"/projects/{projectId}/patches/{patchId}/blobs/0");
143
144 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
145 var content = await response.Content.ReadAsByteArrayAsync();
146 Assert.Equal("content", System.Text.Encoding.UTF8.GetString(content));
147 }
148
149 [Fact]
150 public async Task GetPatchBlob_UnknownIndex_ReturnsNotFound()
151 {
152 var (projectId, patchId) = await SetupProjectWithFileAsync();
153
154 var response = await _client.GetAsync($"/projects/{projectId}/patches/{patchId}/blobs/99");
155
156 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
157 }
158
159 // ===== BLOB INDEX INTEGRITY =====
160
161 [Fact]
162 public async Task BlobIndex_AfterModify_PointsToLatestPatch()
163 {
164 var (projectId, _) = await SetupProjectWithFileAsync("file.txt", "v1");
165
166 // Modify the file
167 var modReq = new CreatePatchRequest
168 {
169 Author = "user",
170 Description = "modify",
171 Maturity = Maturity.DraftSynced,
172 Ops = [new FileOperation { Type = OpType.Modify, File = "file.txt", ContentBase64 = Convert.ToBase64String("v2"u8.ToArray()) }]
173 };
174 var modResp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", modReq);
175 var modPatch = await modResp.Content.ReadFromJsonAsync<PatchResponse>();
176 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{modPatch!.Id}/maturity", new UpdateMaturityRequest(Maturity.Proposed));
177 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{modPatch.Id}/maturity", new UpdateMaturityRequest(Maturity.Accepted));
178
179 // Download should return v2
180 var response = await _client.GetAsync($"/projects/{projectId}/trunk/files/file.txt");
181 var content = await response.Content.ReadAsByteArrayAsync();
182 Assert.Equal("v2", System.Text.Encoding.UTF8.GetString(content));
183 }
184
185 [Fact]
186 public async Task BlobIndex_AfterDelete_FileNotDownloadable()
187 {
188 var (projectId, _) = await SetupProjectWithFileAsync("file.txt", "content");
189
190 var delReq = new CreatePatchRequest
191 {
192 Author = "user",
193 Description = "delete",
194 Maturity = Maturity.DraftSynced,
195 Ops = [new FileOperation { Type = OpType.Delete, File = "file.txt" }]
196 };
197 var delResp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", delReq);
198 var delPatch = await delResp.Content.ReadFromJsonAsync<PatchResponse>();
199 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{delPatch!.Id}/maturity", new UpdateMaturityRequest(Maturity.Proposed));
200 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{delPatch.Id}/maturity", new UpdateMaturityRequest(Maturity.Accepted));
201
202 var response = await _client.GetAsync($"/projects/{projectId}/trunk/files/file.txt");
203 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
204 }
205
206 [Fact]
207 public async Task BlobIndex_AfterRename_AvailableUnderNewName()
208 {
209 var (projectId, _) = await SetupProjectWithFileAsync("old.txt", "content");
210
211 var renReq = new CreatePatchRequest
212 {
213 Author = "user",
214 Description = "rename",
215 Maturity = Maturity.DraftSynced,
216 Ops = [new FileOperation { Type = OpType.Rename, File = "old.txt", NewPath = "new.txt" }]
217 };
218 var renResp = await _client.PostAsJsonAsync($"/projects/{projectId}/patches", renReq);
219 var renPatch = await renResp.Content.ReadFromJsonAsync<PatchResponse>();
220 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{renPatch!.Id}/maturity", new UpdateMaturityRequest(Maturity.Proposed));
221 await _client.PutAsJsonAsync($"/projects/{projectId}/patches/{renPatch.Id}/maturity", new UpdateMaturityRequest(Maturity.Accepted));
222
223 var oldResp = await _client.GetAsync($"/projects/{projectId}/trunk/files/old.txt");
224 Assert.Equal(HttpStatusCode.NotFound, oldResp.StatusCode);
225
226 var newResp = await _client.GetAsync($"/projects/{projectId}/trunk/files/new.txt");
227 Assert.Equal(HttpStatusCode.OK, newResp.StatusCode);
228 var content = await newResp.Content.ReadAsByteArrayAsync();
229 Assert.Equal("content", System.Text.Encoding.UTF8.GetString(content));
230 }
231}
232
+ tests/Catena.Tests/TrunkApiTests.cs
1using System.Net;
2using System.Net.Http.Json;
3using Catena.Shared.Dtos;
4using Microsoft.AspNetCore.Mvc.Testing;
5
6namespace Catena.Tests;
7
8public class TrunkApiTests
9{
10 private readonly HttpClient _client;
11
12 public TrunkApiTests()
13 {
14 var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
15 {
16 builder.UseSetting("Catena:AuthEnabled", "false");
17 builder.UseSetting("Catena:DataDir",
18 Path.Combine(Path.GetTempPath(), $"catena-trunkapi-test-{Guid.NewGuid():N}"));
19 });
20 _client = factory.CreateClient();
21 }
22
23 [Fact]
24 public async Task GetTrunk_NewProject_ReturnsEmptyFiles()
25 {
26 var createResponse = await _client.PostAsJsonAsync("/projects", new CreateProjectRequest("Test"));
27 var project = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
28
29 var trunk = await _client.GetFromJsonAsync<TrunkStateResponse>($"/projects/{project!.Id}/trunk");
30
31 Assert.NotNull(trunk);
32 Assert.Empty(trunk.Files);
33 Assert.NotEmpty(trunk.Hash);
34 }
35
36 [Fact]
37 public async Task GetTrunk_UnknownProject_ReturnsNotFound()
38 {
39 var response = await _client.GetAsync("/projects/doesnotexist/trunk");
40 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
41 }
42}
43
+ tests/Catena.Tests/TrunkUpdateTests.cs
1using Catena.Storage;
2using Catena.Shared.Dtos;
3using Catena.Shared.Models;
4
5namespace Catena.Tests;
6
7public class TrunkUpdateTests : IDisposable
8{
9 private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"catena-trunk-test-{Guid.NewGuid():N}");
10 private readonly PatchStore _patchStore;
11 private readonly ProjectStore _projectStore;
12 private readonly TrunkState _trunk;
13
14 public TrunkUpdateTests()
15 {
16 _patchStore = new PatchStore(_tempDir);
17 _projectStore = new ProjectStore(_tempDir);
18 _trunk = new TrunkState(_tempDir);
19 }
20
21 public void Dispose()
22 {
23 if (Directory.Exists(_tempDir))
24 Directory.Delete(_tempDir, true);
25 }
26
27 [Fact]
28 public async Task InsertOp_AddsFileToTrunk()
29 {
30 var project = await _projectStore.CreateAsync("Test");
31 var request = new CreatePatchRequest
32 {
33 Author = "user",
34 Description = "add file",
35 Ops = [new FileOperation { Type = OpType.Insert, File = "hello.txt", ContentBase64 = Convert.ToBase64String("hello"u8.ToArray()) }]
36 };
37 var patch = await _patchStore.CreateAsync(project.Id, request);
38
39 await _trunk.ApplyPatchAsync(project.Id, patch, _patchStore);
40
41 var files = await _trunk.GetFilesAsync(project.Id);
42 Assert.Single(files);
43 Assert.True(files.ContainsKey("hello.txt"));
44 Assert.NotEmpty(files["hello.txt"]);
45 }
46
47 [Fact]
48 public async Task ModifyOp_UpdatesFileHash()
49 {
50 var project = await _projectStore.CreateAsync("Test");
51
52 // First: insert
53 var insert = new CreatePatchRequest
54 {
55 Author = "user",
56 Description = "add",
57 Ops = [new FileOperation { Type = OpType.Insert, File = "a.txt", ContentBase64 = Convert.ToBase64String("v1"u8.ToArray()) }]
58 };
59 var p1 = await _patchStore.CreateAsync(project.Id, insert);
60 await _trunk.ApplyPatchAsync(project.Id, p1, _patchStore);
61
62 var filesBefore = await _trunk.GetFilesAsync(project.Id);
63 var hashBefore = filesBefore["a.txt"];
64
65 // Then: modify
66 var modify = new CreatePatchRequest
67 {
68 Author = "user",
69 Description = "modify",
70 Ops = [new FileOperation { Type = OpType.Modify, File = "a.txt", ContentBase64 = Convert.ToBase64String("v2"u8.ToArray()) }]
71 };
72 var p2 = await _patchStore.CreateAsync(project.Id, modify);
73 await _trunk.ApplyPatchAsync(project.Id, p2, _patchStore);
74
75 var filesAfter = await _trunk.GetFilesAsync(project.Id);
76 Assert.Single(filesAfter);
77 Assert.NotEqual(hashBefore, filesAfter["a.txt"]);
78 }
79
80 [Fact]
81 public async Task DeleteOp_RemovesFileFromTrunk()
82 {
83 var project = await _projectStore.CreateAsync("Test");
84
85 var insert = new CreatePatchRequest
86 {
87 Author = "user",
88 Description = "add",
89 Ops = [new FileOperation { Type = OpType.Insert, File = "a.txt", ContentBase64 = Convert.ToBase64String("x"u8.ToArray()) }]
90 };
91 var p1 = await _patchStore.CreateAsync(project.Id, insert);
92 await _trunk.ApplyPatchAsync(project.Id, p1, _patchStore);
93
94 var delete = new CreatePatchRequest
95 {
96 Author = "user",
97 Description = "delete",
98 Ops = [new FileOperation { Type = OpType.Delete, File = "a.txt" }]
99 };
100 var p2 = await _patchStore.CreateAsync(project.Id, delete);
101 await _trunk.ApplyPatchAsync(project.Id, p2, _patchStore);
102
103 var files = await _trunk.GetFilesAsync(project.Id);
104 Assert.Empty(files);
105 }
106
107 [Fact]
108 public async Task RenameOp_MovesEntry()
109 {
110 var project = await _projectStore.CreateAsync("Test");
111
112 var insert = new CreatePatchRequest
113 {
114 Author = "user",
115 Description = "add",
116 Ops = [new FileOperation { Type = OpType.Insert, File = "old.txt", ContentBase64 = Convert.ToBase64String("x"u8.ToArray()) }]
117 };
118 var p1 = await _patchStore.CreateAsync(project.Id, insert);
119 await _trunk.ApplyPatchAsync(project.Id, p1, _patchStore);
120
121 var rename = new CreatePatchRequest
122 {
123 Author = "user",
124 Description = "rename",
125 Ops = [new FileOperation { Type = OpType.Rename, File = "old.txt", NewPath = "new.txt" }]
126 };
127 var p2 = await _patchStore.CreateAsync(project.Id, rename);
128 await _trunk.ApplyPatchAsync(project.Id, p2, _patchStore);
129
130 var files = await _trunk.GetFilesAsync(project.Id);
131 Assert.False(files.ContainsKey("old.txt"));
132 Assert.True(files.ContainsKey("new.txt"));
133 }
134
135 [Fact]
136 public async Task MultiplePatchesApplied_Sequentially()
137 {
138 var project = await _projectStore.CreateAsync("Test");
139
140 for (int i = 0; i < 5; i++)
141 {
142 var req = new CreatePatchRequest
143 {
144 Author = "user",
145 Description = $"add file {i}",
146 Ops = [new FileOperation { Type = OpType.Insert, File = $"file{i}.txt", ContentBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"content{i}")) }]
147 };
148 var patch = await _patchStore.CreateAsync(project.Id, req);
149 await _trunk.ApplyPatchAsync(project.Id, patch, _patchStore);
150 }
151
152 var files = await _trunk.GetFilesAsync(project.Id);
153 Assert.Equal(5, files.Count);
154 }
155
156 [Fact]
157 public async Task MixedOps_InSinglePatch()
158 {
159 var project = await _projectStore.CreateAsync("Test");
160
161 // Setup: insert two files
162 var setup = new CreatePatchRequest
163 {
164 Author = "user",
165 Description = "setup",
166 Ops =
167 [
168 new FileOperation { Type = OpType.Insert, File = "keep.txt", ContentBase64 = Convert.ToBase64String("keep"u8.ToArray()) },
169 new FileOperation { Type = OpType.Insert, File = "remove.txt", ContentBase64 = Convert.ToBase64String("remove"u8.ToArray()) }
170 ]
171 };
172 var p1 = await _patchStore.CreateAsync(project.Id, setup);
173 await _trunk.ApplyPatchAsync(project.Id, p1, _patchStore);
174
175 // Mixed patch: modify one, delete one, add one
176 var mixed = new CreatePatchRequest
177 {
178 Author = "user",
179 Description = "mixed",
180 Ops =
181 [
182 new FileOperation { Type = OpType.Modify, File = "keep.txt", ContentBase64 = Convert.ToBase64String("updated"u8.ToArray()) },
183 new FileOperation { Type = OpType.Delete, File = "remove.txt" },
184 new FileOperation { Type = OpType.Insert, File = "new.txt", ContentBase64 = Convert.ToBase64String("new"u8.ToArray()) }
185 ]
186 };
187 var p2 = await _patchStore.CreateAsync(project.Id, mixed);
188 await _trunk.ApplyPatchAsync(project.Id, p2, _patchStore);
189
190 var files = await _trunk.GetFilesAsync(project.Id);
191 Assert.Equal(2, files.Count);
192 Assert.True(files.ContainsKey("keep.txt"));
193 Assert.True(files.ContainsKey("new.txt"));
194 Assert.False(files.ContainsKey("remove.txt"));
195 }
196}
197
+ tests/Catena.Tests/UserStoreTests.cs
1using Catena.Shared.Models;
2using Catena.Storage;
3
4namespace Catena.Tests;
5
6public class UserStoreTests : IDisposable
7{
8 private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"catena-user-test-{Guid.NewGuid():N}");
9 private readonly UserStore _store;
10
11 public UserStoreTests()
12 {
13 _store = new UserStore(_tempDir);
14 }
15
16 public void Dispose()
17 {
18 if (Directory.Exists(_tempDir))
19 Directory.Delete(_tempDir, true);
20 }
21
22 [Fact]
23 public async Task CreateAsync_ReturnsUserAndKey()
24 {
25 var (user, key) = await _store.CreateAsync("alice", UserRole.Developer);
26
27 Assert.NotNull(user.Id);
28 Assert.Equal(12, user.Id.Length);
29 Assert.Equal("alice", user.Name);
30 Assert.Equal(UserRole.Developer, user.Role);
31 Assert.NotEmpty(key);
32 Assert.NotEmpty(user.ApiKeyHash);
33 }
34
35 [Fact]
36 public async Task GetAsync_ReturnsCreatedUser()
37 {
38 var (user, _) = await _store.CreateAsync("bob", UserRole.Maintainer);
39
40 var loaded = await _store.GetAsync(user.Id);
41
42 Assert.NotNull(loaded);
43 Assert.Equal(user.Id, loaded.Id);
44 Assert.Equal("bob", loaded.Name);
45 Assert.Equal(UserRole.Maintainer, loaded.Role);
46 }
47
48 [Fact]
49 public async Task GetAsync_UnknownId_ReturnsNull()
50 {
51 var result = await _store.GetAsync("unknown");
52 Assert.Null(result);
53 }
54
55 [Fact]
56 public async Task GetByApiKeyAsync_FindsUser()
57 {
58 var (user, key) = await _store.CreateAsync("carol", UserRole.Admin);
59
60 var found = await _store.GetByApiKeyAsync(key);
61
62 Assert.NotNull(found);
63 Assert.Equal(user.Id, found.Id);
64 }
65
66 [Fact]
67 public async Task GetByApiKeyAsync_WrongKey_ReturnsNull()
68 {
69 await _store.CreateAsync("dave", UserRole.Developer);
70
71 var found = await _store.GetByApiKeyAsync("wrong-key");
72
73 Assert.Null(found);
74 }
75
76 [Fact]
77 public async Task ListAsync_ReturnsAllUsers()
78 {
79 await _store.CreateAsync("alice", UserRole.Developer);
80 await _store.CreateAsync("bob", UserRole.Maintainer);
81
82 var users = await _store.ListAsync();
83
84 Assert.Equal(2, users.Count);
85 }
86
87 [Fact]
88 public async Task DeleteUser_RemovesUser()
89 {
90 var (user, _) = await _store.CreateAsync("temp", UserRole.Developer);
91
92 var deleted = _store.DeleteUser(user.Id);
93 Assert.True(deleted);
94
95 var loaded = await _store.GetAsync(user.Id);
96 Assert.Null(loaded);
97 }
98
99 [Fact]
100 public void DeleteUser_UnknownId_ReturnsFalse()
101 {
102 Assert.False(_store.DeleteUser("unknown"));
103 }
104
105 [Fact]
106 public async Task HasAnyUsers_InitiallyFalse()
107 {
108 Assert.False(_store.HasAnyUsers());
109
110 await _store.CreateAsync("first", UserRole.Admin);
111
112 Assert.True(_store.HasAnyUsers());
113 }
114
115 [Fact]
116 public async Task ApiKeys_AreUnique()
117 {
118 var (_, key1) = await _store.CreateAsync("a", UserRole.Developer);
119 var (_, key2) = await _store.CreateAsync("b", UserRole.Developer);
120
121 Assert.NotEqual(key1, key2);
122 }
123
124 [Fact]
125 public async Task KeyHash_IsDeterministic()
126 {
127 var hash1 = UserStore.HashApiKey("test-key");
128 var hash2 = UserStore.HashApiKey("test-key");
129
130 Assert.Equal(hash1, hash2);
131 }
132
133 [Fact]
134 public async Task KeyHash_DifferentKeys_DifferentHashes()
135 {
136 var hash1 = UserStore.HashApiKey("key-a");
137 var hash2 = UserStore.HashApiKey("key-b");
138
139 Assert.NotEqual(hash1, hash2);
140 await Task.CompletedTask;
141 }
142}
143
+ tests/Catena.Tests/WebhookStoreTests.cs
1using Catena.Storage;
2
3namespace Catena.Tests;
4
5public class WebhookStoreTests : IDisposable
6{
7 private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"catena-webhook-test-{Guid.NewGuid():N}");
8 private readonly WebhookStore _store;
9 private readonly ProjectStore _projectStore;
10
11 public WebhookStoreTests()
12 {
13 _store = new WebhookStore(_tempDir);
14 _projectStore = new ProjectStore(_tempDir);
15 }
16
17 public void Dispose()
18 {
19 if (Directory.Exists(_tempDir))
20 Directory.Delete(_tempDir, true);
21 }
22
23 [Fact]
24 public async Task CreateAsync_ReturnsWebhook()
25 {
26 var project = await _projectStore.CreateAsync("Test");
27 var hook = await _store.CreateAsync(project.Id, "patch.proposed", "https://ci.example.com/hook");
28
29 Assert.NotNull(hook.Id);
30 Assert.Equal("patch.proposed", hook.Event);
31 Assert.Equal("https://ci.example.com/hook", hook.Url);
32 Assert.Equal(project.Id, hook.ProjectId);
33 }
34
35 [Fact]
36 public async Task ListAsync_ReturnsAll()
37 {
38 var project = await _projectStore.CreateAsync("Test");
39 await _store.CreateAsync(project.Id, "patch.proposed", "https://a.com");
40 await _store.CreateAsync(project.Id, "patch.accepted", "https://b.com");
41
42 var hooks = await _store.ListAsync(project.Id);
43 Assert.Equal(2, hooks.Count);
44 }
45
46 [Fact]
47 public async Task ListAsync_Empty_ReturnsEmpty()
48 {
49 var hooks = await _store.ListAsync("noproject");
50 Assert.Empty(hooks);
51 }
52
53 [Fact]
54 public async Task Delete_RemovesWebhook()
55 {
56 var project = await _projectStore.CreateAsync("Test");
57 var hook = await _store.CreateAsync(project.Id, "patch.proposed", "https://a.com");
58
59 Assert.True(_store.Delete(project.Id, hook.Id));
60 var hooks = await _store.ListAsync(project.Id);
61 Assert.Empty(hooks);
62 }
63
64 [Fact]
65 public void Delete_Unknown_ReturnsFalse()
66 {
67 Assert.False(_store.Delete("fake", "fake"));
68 }
69}
70
+ tests/Catena.Tests/WorkspaceDiffTests.cs
1using Catena.Shared.Tracking;
2
3namespace Catena.Tests;
4
5public class WorkspaceDiffTests
6{
7 [Fact]
8 public void NoChanges_HasChangesIsFalse()
9 {
10 var files = new Dictionary<string, string> { ["a.txt"] = "hash1" };
11 var diff = WorkspaceDiff.Compute(files, files);
12
13 Assert.False(diff.HasChanges);
14 Assert.Empty(diff.Added);
15 Assert.Empty(diff.Modified);
16 Assert.Empty(diff.Deleted);
17 }
18
19 [Fact]
20 public void NewFile_DetectedAsAdded()
21 {
22 var local = new Dictionary<string, string> { ["a.txt"] = "hash1", ["b.txt"] = "hash2" };
23 var trunk = new Dictionary<string, string> { ["a.txt"] = "hash1" };
24
25 var diff = WorkspaceDiff.Compute(local, trunk);
26
27 Assert.Single(diff.Added);
28 Assert.Contains("b.txt", diff.Added);
29 Assert.Empty(diff.Modified);
30 Assert.Empty(diff.Deleted);
31 }
32
33 [Fact]
34 public void ChangedHash_DetectedAsModified()
35 {
36 var local = new Dictionary<string, string> { ["a.txt"] = "newhash" };
37 var trunk = new Dictionary<string, string> { ["a.txt"] = "oldhash" };
38
39 var diff = WorkspaceDiff.Compute(local, trunk);
40
41 Assert.Empty(diff.Added);
42 Assert.Single(diff.Modified);
43 Assert.Contains("a.txt", diff.Modified);
44 Assert.Empty(diff.Deleted);
45 }
46
47 [Fact]
48 public void MissingLocally_DetectedAsDeleted()
49 {
50 var local = new Dictionary<string, string>();
51 var trunk = new Dictionary<string, string> { ["a.txt"] = "hash1" };
52
53 var diff = WorkspaceDiff.Compute(local, trunk);
54
55 Assert.Empty(diff.Added);
56 Assert.Empty(diff.Modified);
57 Assert.Single(diff.Deleted);
58 Assert.Contains("a.txt", diff.Deleted);
59 }
60
61 [Fact]
62 public void MixedChanges_AllDetected()
63 {
64 var local = new Dictionary<string, string>
65 {
66 ["kept.txt"] = "same",
67 ["modified.txt"] = "new",
68 ["added.txt"] = "fresh"
69 };
70 var trunk = new Dictionary<string, string>
71 {
72 ["kept.txt"] = "same",
73 ["modified.txt"] = "old",
74 ["removed.txt"] = "gone"
75 };
76
77 var diff = WorkspaceDiff.Compute(local, trunk);
78
79 Assert.Single(diff.Added);
80 Assert.Contains("added.txt", diff.Added);
81 Assert.Single(diff.Modified);
82 Assert.Contains("modified.txt", diff.Modified);
83 Assert.Single(diff.Deleted);
84 Assert.Contains("removed.txt", diff.Deleted);
85 }
86
87 [Fact]
88 public void EmptyTrunk_AllFilesAreAdded()
89 {
90 var local = new Dictionary<string, string>
91 {
92 ["a.txt"] = "h1",
93 ["b.txt"] = "h2"
94 };
95 var trunk = new Dictionary<string, string>();
96
97 var diff = WorkspaceDiff.Compute(local, trunk);
98
99 Assert.Equal(2, diff.Added.Count);
100 Assert.Empty(diff.Modified);
101 Assert.Empty(diff.Deleted);
102 }
103
104 [Fact]
105 public void Results_AreSorted()
106 {
107 var local = new Dictionary<string, string>
108 {
109 ["z.txt"] = "h1",
110 ["a.txt"] = "h2",
111 ["m.txt"] = "h3"
112 };
113 var trunk = new Dictionary<string, string>();
114
115 var diff = WorkspaceDiff.Compute(local, trunk);
116
117 Assert.Equal(["a.txt", "m.txt", "z.txt"], diff.Added);
118 }
119}
120
+ tests/Catena.Tests/WorkspaceScannerTests.cs
1using Catena.Shared.Tracking;
2
3namespace Catena.Tests;
4
5public class WorkspaceScannerTests : IDisposable
6{
7 private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"catena-scan-test-{Guid.NewGuid():N}");
8
9 public WorkspaceScannerTests()
10 {
11 Directory.CreateDirectory(_tempDir);
12 }
13
14 public void Dispose()
15 {
16 if (Directory.Exists(_tempDir))
17 Directory.Delete(_tempDir, true);
18 }
19
20 [Fact]
21 public async Task ScanAsync_FindsFiles()
22 {
23 await File.WriteAllTextAsync(Path.Combine(_tempDir, "hello.txt"), "hello");
24 Directory.CreateDirectory(Path.Combine(_tempDir, "sub"));
25 await File.WriteAllTextAsync(Path.Combine(_tempDir, "sub", "world.txt"), "world");
26
27 var filter = new IgnoreFilter([]);
28 var files = await WorkspaceScanner.ScanAsync(_tempDir, filter);
29
30 Assert.Equal(2, files.Count);
31 Assert.True(files.ContainsKey("hello.txt"));
32 Assert.True(files.ContainsKey("sub/world.txt"));
33 }
34
35 [Fact]
36 public async Task ScanAsync_RespectsIgnoreFilter()
37 {
38 await File.WriteAllTextAsync(Path.Combine(_tempDir, "keep.txt"), "keep");
39 Directory.CreateDirectory(Path.Combine(_tempDir, "bin"));
40 await File.WriteAllTextAsync(Path.Combine(_tempDir, "bin", "app.dll"), "binary");
41
42 var filter = new IgnoreFilter(["bin/"]);
43 var files = await WorkspaceScanner.ScanAsync(_tempDir, filter);
44
45 Assert.Single(files);
46 Assert.True(files.ContainsKey("keep.txt"));
47 }
48
49 [Fact]
50 public async Task ScanAsync_ProducesDeterministicHashes()
51 {
52 await File.WriteAllTextAsync(Path.Combine(_tempDir, "file.txt"), "same content");
53
54 var filter = new IgnoreFilter([]);
55 var scan1 = await WorkspaceScanner.ScanAsync(_tempDir, filter);
56 var scan2 = await WorkspaceScanner.ScanAsync(_tempDir, filter);
57
58 Assert.Equal(scan1["file.txt"], scan2["file.txt"]);
59 }
60
61 [Fact]
62 public async Task ScanAsync_DifferentContentDifferentHash()
63 {
64 await File.WriteAllTextAsync(Path.Combine(_tempDir, "a.txt"), "aaa");
65 await File.WriteAllTextAsync(Path.Combine(_tempDir, "b.txt"), "bbb");
66
67 var filter = new IgnoreFilter([]);
68 var files = await WorkspaceScanner.ScanAsync(_tempDir, filter);
69
70 Assert.NotEqual(files["a.txt"], files["b.txt"]);
71 }
72
73 [Fact]
74 public async Task ScanAsync_EmptyDirectory_ReturnsEmpty()
75 {
76 var filter = new IgnoreFilter([]);
77 var files = await WorkspaceScanner.ScanAsync(_tempDir, filter);
78 Assert.Empty(files);
79 }
80}
81