141 lines
5.4 KiB
C#
141 lines
5.4 KiB
C#
|
|
using System.IO;
|
||
|
|
using System.Text.Json;
|
||
|
|
using System.Text.RegularExpressions;
|
||
|
|
|
||
|
|
namespace ClaudeOverview.Services;
|
||
|
|
|
||
|
|
/// <summary>Settings loaded from config.json next to the executable.</summary>
|
||
|
|
public sealed class AppConfig
|
||
|
|
{
|
||
|
|
public string WslDistro { get; set; } = "Ubuntu";
|
||
|
|
public string LinuxHome { get; set; } = "/home/philipp";
|
||
|
|
public string ProjectsPathOverride { get; set; } = "";
|
||
|
|
public int ActiveMinutes { get; set; } = 5;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Fallback window (minutes) for showing "cold" (inactive) chats when the session-limit reset
|
||
|
|
/// time isn't available. Normally the cold cutoff is the start of the current session-limit
|
||
|
|
/// window (its reset time minus the 5-hour block); this is only used when that's unknown.
|
||
|
|
/// </summary>
|
||
|
|
public int ColdMinutes { get; set; } = 1440;
|
||
|
|
public int RefreshSeconds { get; set; } = 4;
|
||
|
|
|
||
|
|
/// <summary>Seconds a pending tool may sit before we treat it as a permission prompt (vs still running).</summary>
|
||
|
|
public int PendingPermissionSeconds { get; set; } = 8;
|
||
|
|
public int MonitorIndex { get; set; } = -1;
|
||
|
|
public int EdgeMargin { get; set; } = 12;
|
||
|
|
public bool OnlyVsCode { get; set; } = true;
|
||
|
|
public bool StartMinimized { get; set; } = true;
|
||
|
|
public bool FlashOnAttention { get; set; } = true;
|
||
|
|
public bool SoundOnAttention { get; set; } = false;
|
||
|
|
public bool ShowSessionLimit { get; set; } = true;
|
||
|
|
|
||
|
|
/// <summary>How often to poll the subscription usage endpoint (changes slowly).</summary>
|
||
|
|
public int UsageRefreshSeconds { get; set; } = 60;
|
||
|
|
|
||
|
|
/// <summary>Path to .credentials.json (sibling of the projects dir, i.e. inside .claude).</summary>
|
||
|
|
public string CredentialsPath
|
||
|
|
{
|
||
|
|
get
|
||
|
|
{
|
||
|
|
var claudeDir = Path.GetDirectoryName(ProjectsPath);
|
||
|
|
return claudeDir is null
|
||
|
|
? ""
|
||
|
|
: Path.Combine(claudeDir, ".credentials.json");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Resolves the Windows path to the .claude/projects directory.</summary>
|
||
|
|
public string ProjectsPath
|
||
|
|
{
|
||
|
|
get
|
||
|
|
{
|
||
|
|
if (!string.IsNullOrWhiteSpace(ProjectsPathOverride))
|
||
|
|
return ProjectsPathOverride;
|
||
|
|
|
||
|
|
// \\wsl.localhost\<Distro>\<linuxHome>\.claude\projects
|
||
|
|
var home = LinuxHome.Replace('/', '\\').TrimStart('\\');
|
||
|
|
return $@"\\wsl.localhost\{WslDistro}\{home}\.claude\projects";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Translates a Linux path from a transcript (e.g. <c>/home/philipp/git/foo</c>) to the Windows
|
||
|
|
/// UNC path that reaches it over the WSL share (<c>\\wsl.localhost\Ubuntu\home\philipp\git\foo</c>).
|
||
|
|
/// </summary>
|
||
|
|
public string ToWindowsPath(string linuxPath)
|
||
|
|
{
|
||
|
|
var rel = linuxPath.Replace('/', '\\').TrimStart('\\');
|
||
|
|
return $@"\\wsl.localhost\{WslDistro}\{rel}";
|
||
|
|
}
|
||
|
|
|
||
|
|
public static AppConfig Load()
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var path = Path.Combine(AppContext.BaseDirectory, "config.json");
|
||
|
|
if (!File.Exists(path)) return new AppConfig();
|
||
|
|
|
||
|
|
var json = File.ReadAllText(path);
|
||
|
|
var opts = new JsonSerializerOptions
|
||
|
|
{
|
||
|
|
PropertyNameCaseInsensitive = true,
|
||
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||
|
|
AllowTrailingCommas = true
|
||
|
|
};
|
||
|
|
return JsonSerializer.Deserialize<AppConfig>(json, opts) ?? new AppConfig();
|
||
|
|
}
|
||
|
|
catch
|
||
|
|
{
|
||
|
|
return new AppConfig();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string ConfigFilePath => Path.Combine(AppContext.BaseDirectory, "config.json");
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Persists the runtime-editable settings back to config.json, updating values in place
|
||
|
|
/// so the file's comments and other keys are preserved.
|
||
|
|
/// </summary>
|
||
|
|
public void Save()
|
||
|
|
{
|
||
|
|
string text = File.Exists(ConfigFilePath)
|
||
|
|
? File.ReadAllText(ConfigFilePath)
|
||
|
|
: "{\n}\n";
|
||
|
|
|
||
|
|
text = UpsertInt(text, "monitorIndex", MonitorIndex);
|
||
|
|
text = UpsertInt(text, "activeMinutes", ActiveMinutes);
|
||
|
|
text = UpsertInt(text, "refreshSeconds", RefreshSeconds);
|
||
|
|
text = UpsertBool(text, "flashOnAttention", FlashOnAttention);
|
||
|
|
text = UpsertBool(text, "soundOnAttention", SoundOnAttention);
|
||
|
|
|
||
|
|
File.WriteAllText(ConfigFilePath, text);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string UpsertBool(string json, string key, bool value)
|
||
|
|
{
|
||
|
|
var rx = new Regex($"(?<!//\\s)(\"{Regex.Escape(key)}\"\\s*:\\s*)(?:true|false)");
|
||
|
|
var literal = value ? "true" : "false";
|
||
|
|
if (rx.IsMatch(json))
|
||
|
|
return rx.Replace(json, m => m.Groups[1].Value + literal, 1);
|
||
|
|
|
||
|
|
int brace = json.IndexOf('{');
|
||
|
|
if (brace < 0) return json;
|
||
|
|
return json.Insert(brace + 1, $"\n \"{key}\": {literal},");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Replaces the integer value of a JSON key, or inserts the key if missing.</summary>
|
||
|
|
private static string UpsertInt(string json, string key, int value)
|
||
|
|
{
|
||
|
|
// Match the *real* key, not a "// key" comment line: require it isn't preceded by "// ".
|
||
|
|
var rx = new Regex($"(?<!//\\s)(\"{Regex.Escape(key)}\"\\s*:\\s*)-?\\d+");
|
||
|
|
if (rx.IsMatch(json))
|
||
|
|
return rx.Replace(json, m => m.Groups[1].Value + value, 1);
|
||
|
|
|
||
|
|
// Not present — insert right after the opening brace.
|
||
|
|
int brace = json.IndexOf('{');
|
||
|
|
if (brace < 0) return json;
|
||
|
|
return json.Insert(brace + 1, $"\n \"{key}\": {value},");
|
||
|
|
}
|
||
|
|
}
|