Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions PolyPilot.Tests/ConnectionSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,31 @@ public void NormalizeRemoteUrl_DoesNotDoubleScheme()
Assert.Equal("http://http://example.com", result);
}

[Fact]
public void RepoStorageDir_DefaultsToNull()
{
var settings = new ConnectionSettings();
Assert.Null(settings.RepoStorageDir);
}

[Fact]
public void RepoStorageDir_RoundTripsViaSerialization()
{
var original = new ConnectionSettings { RepoStorageDir = @"D:\code\polypilot" };
var json = System.Text.Json.JsonSerializer.Serialize(original);
var loaded = System.Text.Json.JsonSerializer.Deserialize<ConnectionSettings>(json)!;
Assert.Equal(@"D:\code\polypilot", loaded.RepoStorageDir);
}

[Fact]
public void RepoStorageDir_NullRoundTripsViaSerialization()
{
var original = new ConnectionSettings { RepoStorageDir = null };
var json = System.Text.Json.JsonSerializer.Serialize(original);
var loaded = System.Text.Json.JsonSerializer.Deserialize<ConnectionSettings>(json)!;
Assert.Null(loaded.RepoStorageDir);
}

private void Dispose()
{
try { Directory.Delete(_testDir, true); } catch { }
Expand Down
60 changes: 60 additions & 0 deletions PolyPilot.Tests/RepoManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -366,4 +366,64 @@ public void Load_WithCorruptedState_HealsFromDisk()
}

#endregion

#region Custom Storage Dir Tests

[Fact]
public void SetCustomStorageDir_ChangesReposDirAndWorktreesDir()
{
var customDir = Path.Combine(Path.GetTempPath(), $"custom-storage-{Guid.NewGuid():N}");
Directory.CreateDirectory(customDir);
try
{
RepoManager.SetCustomStorageDir(customDir);
try
{
var effectiveDir = RepoManager.GetEffectiveStorageDir();
Assert.Equal(customDir, effectiveDir);
}
finally
{
// Restore test isolation
RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir);
}
}
finally
{
Directory.Delete(customDir, true);
}
}

[Fact]
public void SetCustomStorageDir_Null_FallsBackToDefault()
{
RepoManager.SetCustomStorageDir(null);
try
{
var effectiveDir = RepoManager.GetEffectiveStorageDir();
// Should be the test base dir (set by SetBaseDirForTesting in TestSetup)
Assert.Equal(TestSetup.TestBaseDir, effectiveDir);
}
finally
{
RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir);
}
}

[Fact]
public void SetCustomStorageDir_WhitespaceString_TreatedAsNull()
{
RepoManager.SetCustomStorageDir(" ");
try
{
var effectiveDir = RepoManager.GetEffectiveStorageDir();
Assert.Equal(TestSetup.TestBaseDir, effectiveDir);
}
finally
{
RepoManager.SetBaseDirForTesting(TestSetup.TestBaseDir);
}
}

#endregion
}
46 changes: 46 additions & 0 deletions PolyPilot/Components/Pages/Settings.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
@inject GitAutoUpdateService GitAutoUpdate
@inject FiestaService FiestaService
@inject UsageStatsService UsageStats
@inject RepoManager RepoManager
@inject NavigationManager Nav
@inject IJSRuntime JS
@inject IServiceProvider ServiceProvider
Expand Down Expand Up @@ -413,6 +414,30 @@
</div>
}

@if (PlatformHelper.IsDesktop)
{
<div class="settings-group @(GroupVisible("repositories") ? "" : "search-hidden")">
<h2 class="group-title">Repositories</h2>

<div class="settings-section @(SectionVisible("repo storage location clone directory path devdrive") ? "" : "search-hidden")">
<h3>Storage Location</h3>
<p class="section-desc">Directory where bare repo clones and worktrees are stored. Leave blank to use the default <code>~/.polypilot</code> location. Newly cloned repositories will use this location; existing repos keep their current paths.</p>
<div class="repo-storage-row">
<input type="text" class="form-input wide" id="repo-storage-dir"
placeholder="Default: ~/.polypilot/repos"
value="@(settings.RepoStorageDir ?? "")" />
<button class="save-btn" @onclick="SaveRepoStorageDir">
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save
</button>
</div>
@if (!string.IsNullOrEmpty(settings.RepoStorageDir))
{
<div class="cli-path-info">📁 @settings.RepoStorageDir</div>
}
</div>
</div>
}

<div class="settings-group @(GroupVisible("ui") ? "" : "search-hidden")">
<h2 class="group-title">UI</h2>

Expand Down Expand Up @@ -654,6 +679,7 @@
return groupKeyword switch
{
"connection" => SectionVisible("transport mode embedded persistent remote server port start stop pid devtunnel share tunnel mobile qr url token connect save reconnect"),
"repositories" => SectionVisible("repo storage location clone directory path devdrive"),
"ui" => SectionVisible("chat message layout default reversed both left theme font size text zoom"),
"statistics" => SectionVisible("statistics usage stats sessions time code"),
"developer" => SectionVisible("auto update main git watch relaunch rebuild"),
Expand Down Expand Up @@ -1293,4 +1319,24 @@
return $"{seconds / 3600}h {(seconds % 3600) / 60}m";
return $"{seconds / 86400}d {(seconds % 86400) / 3600}h";
}

private async Task SaveRepoStorageDir()
{
var raw = await JS.InvokeAsync<string>("eval", "document.getElementById('repo-storage-dir')?.value ?? ''");
var path = string.IsNullOrWhiteSpace(raw) ? null : raw.Trim();
if (path != null && !Directory.Exists(path))
{
try { Directory.CreateDirectory(path); }
catch (Exception ex)
{
ShowStatus($"Cannot create directory: {path} ({ex.Message})", "error", 5000);
return;
}
}
settings.RepoStorageDir = path;
settings.Save();
RepoManager.SetCustomStorageDir(path);
ShowStatus("Repository storage location saved.", "success");
StateHasChanged();
}
}
7 changes: 7 additions & 0 deletions PolyPilot/Components/Pages/Settings.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,13 @@
border-top: 1px solid var(--control-border);
}

.repo-storage-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}

.toggle-label {
display: flex;
align-items: center;
Expand Down
8 changes: 7 additions & 1 deletion PolyPilot/MauiProgram.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using PolyPilot.Services;
using PolyPilot.Models;
using Microsoft.Extensions.Logging;
using ZXing.Net.Maui.Controls;
using MauiDevFlow.Agent;
Expand Down Expand Up @@ -113,7 +114,12 @@ public static MauiApp CreateMauiApp()
builder.AddMauiBlazorDevFlowTools();
#endif

return builder.Build();
var app = builder.Build();
// Apply custom repo storage directory from settings before any RepoManager access.
// Failure is intentionally swallowed — a bad path won't prevent the app from starting;
// RepoManager will fall back to the default ~/.polypilot location.
try { RepoManager.SetCustomStorageDir(ConnectionSettings.Load().RepoStorageDir); } catch { }
return app;
}

private static void LogException(string source, Exception? ex)
Expand Down
5 changes: 5 additions & 0 deletions PolyPilot/Models/ConnectionSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ public class ConnectionSettings
public List<string> DisabledMcpServers { get; set; } = new();
public List<string> DisabledPlugins { get; set; } = new();
public bool EnableSessionNotifications { get; set; } = false;
/// <summary>
/// Custom directory for storing bare repo clones and worktrees.
/// When null, uses the default ~/.polypilot location.
/// </summary>
public string? RepoStorageDir { get; set; }

/// <summary>
/// Normalizes a remote URL by ensuring it has an http(s):// scheme.
Expand Down
55 changes: 53 additions & 2 deletions PolyPilot/Services/RepoManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace PolyPilot.Services;
public class RepoManager
{
private static string? _baseDirOverride;
private static string? _customStorageDir;
private static readonly object _pathLock = new();
private static string? _reposDir;
private static string ReposDir { get { lock (_pathLock) return _reposDir ??= GetReposDir(); } }
Expand All @@ -28,12 +29,51 @@ internal static void SetBaseDirForTesting(string? path)
lock (_pathLock)
{
_baseDirOverride = path;
_customStorageDir = null;
_reposDir = null;
_worktreesDir = null;
_stateFile = null;
}
}

/// <summary>
/// Set a custom directory for storing bare repo clones and worktrees.
/// When non-null, repos go to &lt;customDir&gt;/repos/ and worktrees to &lt;customDir&gt;/worktrees/.
/// The state file (repos.json) remains in the default ~/.polypilot location.
/// </summary>
public static void SetCustomStorageDir(string? path)
{
lock (_pathLock)
{
_customStorageDir = string.IsNullOrWhiteSpace(path) ? null : path.Trim();
_reposDir = null;
_worktreesDir = null;
}
}

/// <summary>
/// Returns the effective directory used for repos and worktrees.
/// </summary>
public static string GetEffectiveStorageDir()
{
lock (_pathLock)
{
if (_customStorageDir != null) return _customStorageDir;
if (_baseDirOverride != null) return _baseDirOverride;
try
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(home))
home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(home, ".polypilot");
}
catch
{
return Path.Combine(Path.GetTempPath(), ".polypilot");
}
}
}

private RepositoryState _state = new();
private bool _loaded;
private bool _loadedSuccessfully;
Expand Down Expand Up @@ -66,8 +106,19 @@ private static string GetBaseDir()
}
}

private static string GetReposDir() => Path.Combine(GetBaseDir(), "repos");
private static string GetWorktreesDir() => Path.Combine(GetBaseDir(), "worktrees");
private static string GetReposDir()
{
// _customStorageDir overrides where repos are stored (but not the state file)
if (_customStorageDir != null) return Path.Combine(_customStorageDir, "repos");
return Path.Combine(GetBaseDir(), "repos");
}

private static string GetWorktreesDir()
{
if (_customStorageDir != null) return Path.Combine(_customStorageDir, "worktrees");
return Path.Combine(GetBaseDir(), "worktrees");
}

private static string GetStateFile() => Path.Combine(GetBaseDir(), "repos.json");

public void Load()
Expand Down