Skip to content
Merged
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
56 changes: 41 additions & 15 deletions src/CodeShellManager/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -228,23 +228,49 @@
</Border>

<!-- ── Sidebar ──────────────────────────────────────────────────── -->
<Border DockPanel.Dock="Left" Width="230" Background="#181825"
<Border DockPanel.Dock="Left" Background="#181825"
BorderThickness="0,0,1,0" BorderBrush="#313244">
<DockPanel>
<Border DockPanel.Dock="Top" Padding="12,8,8,8" BorderThickness="0,0,0,1"
BorderBrush="#313244">
<DockPanel>
<TextBlock Text="SESSIONS" Foreground="#6c7086" FontSize="10"
FontWeight="Bold" VerticalAlignment="Center"/>
<Button DockPanel.Dock="Right" Content="+" Style="{StaticResource ToolBtn}"
ToolTip="New Session (Ctrl+T)" Click="NewSession_Click"
Width="24" Height="24" FontSize="16" Foreground="#a6e3a1"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="GroupStripCol" Width="0"/>
<ColumnDefinition Width="230"/>
</Grid.ColumnDefinitions>

<!-- Group tab strip (vertical column of category tabs).
Hidden by default; MainWindow toggles GroupStripCol width on/off. -->
<Border Grid.Column="0" x:Name="GroupStripBorder"
Background="#11111b" BorderThickness="0,0,1,0"
BorderBrush="#313244" Visibility="Collapsed">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel x:Name="GroupStripPanel"
AutomationProperties.AutomationId="GroupStripPanel"
Margin="0,6,0,6"/>
</ScrollViewer>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="SidebarSessionList" AutomationProperties.AutomationId="SidebarSessionList" Margin="6,6"/>
</ScrollViewer>
</DockPanel>

<DockPanel Grid.Column="1">
<Border DockPanel.Dock="Top" Padding="12,8,8,8" BorderThickness="0,0,0,1"
BorderBrush="#313244">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="SESSIONS" Foreground="#6c7086" FontSize="10"
FontWeight="Bold" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="+" Style="{StaticResource ToolBtn}"
ToolTip="New Session (Ctrl+T)" Click="NewSession_Click"
Width="26" Height="24" FontSize="16" Foreground="#a6e3a1"
VerticalAlignment="Center" Margin="0,0,2,0"
Padding="0,0,0,2"/>
</Grid>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="SidebarSessionList" AutomationProperties.AutomationId="SidebarSessionList" Margin="6,6"/>
</ScrollViewer>
</DockPanel>
</Grid>
</Border>

<!-- ── Terminal grid ────────────────────────────────────────────── -->
Expand Down
1,598 changes: 1,561 additions & 37 deletions src/CodeShellManager/MainWindow.xaml.cs

Large diffs are not rendered by default.

43 changes: 42 additions & 1 deletion src/CodeShellManager/Models/AppState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

namespace CodeShellManager.Models;

/// <summary>
/// How the sidebar surfaces session groups.
/// None = no group UI at all (flat session list). FilterStrip = vertical tab strip
/// to the left of the sidebar, one filter at a time (default). InlineHeaders =
/// collapsible group headers inline in the sidebar, all groups visible at once.
/// </summary>
public enum GroupDisplayMode
{
None,
FilterStrip,
InlineHeaders
}

public class AppSettings
{
public bool AutoRestoreSessions { get; set; } = true;
Expand All @@ -13,6 +26,34 @@ public class AppSettings
public string DefaultCommand { get; set; } = "claude";
public string DefaultWorkingFolder { get; set; } = "";
public bool ShowGitBranch { get; set; } = true;
/// <summary>Authoritative grouping UI selector. Replaces the legacy <see cref="ShowGroupsTab"/> boolean.</summary>
public GroupDisplayMode GroupDisplayMode { get; set; } = GroupDisplayMode.FilterStrip;
/// <summary>
/// Legacy flag — kept for back-compat with older state.json files. When deserialized
/// as false on a state that still has GroupDisplayMode at its default, the loader
/// migrates the mode to None. Newer code paths read GroupDisplayMode instead.
/// </summary>
public bool ShowGroupsTab { get; set; } = true;
/// <summary>
/// When 2+ adjacent visible sessions share a repo root, draw a small header above
/// them ("📁 repoName (N)") to make the worktree grouping obvious. Off = the
/// implicit subtitle + shared stripe color are the only signals.
/// </summary>
public bool ShowWorktreeClusters { get; set; } = true;
/// <summary>
/// Expand/collapse state of the implicit Ungrouped header in InlineHeaders mode.
/// Real groups carry their own <see cref="SessionGroup.IsExpanded"/> bit; this holds
/// the equivalent for the Ungrouped pseudo-section so it persists across restarts.
/// </summary>
public bool UngroupedSectionExpanded { get; set; } = true;
/// <summary>
/// One-shot guard for the legacy auto-created "Default" group migration in
/// <see cref="Services.SessionManager.LoadFromState"/>. Without this gate the
/// heuristic (single group, name "Default", SortOrder 0) could wipe a user-named
/// "Default" group on a later restart. Flipped to true after the first load
/// regardless of whether the heuristic matched.
/// </summary>
public bool LegacyDefaultGroupCleared { get; set; } = false;
public bool SearchCollapseAfterNavigate { get; set; } = true;
public string Theme { get; set; } = "dark";
public int MaxSearchResults { get; set; } = 100;
Expand Down Expand Up @@ -55,7 +96,7 @@ public class WindowBounds
public class AppState
{
public List<ShellSession> Sessions { get; set; } = [];
public List<SessionGroup> Groups { get; set; } = [new SessionGroup { Name = "Default" }];
public List<SessionGroup> Groups { get; set; } = [];
public string LastLayout { get; set; } = "Single";
public AppSettings Settings { get; set; } = new();

Expand Down
154 changes: 146 additions & 8 deletions src/CodeShellManager/Services/GitService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;

Expand Down Expand Up @@ -28,7 +29,142 @@ public static class GitService
}
}

/// <summary>
/// Returns the canonical "repo identity" path — the parent of the shared .git
/// directory (`git rev-parse --git-common-dir`). This is identical for every
/// worktree of the same repo, so it's safe to use as a sibling-detection key.
/// (`--show-toplevel` would return each worktree's own folder, missing siblings.)
/// Returns null if folderPath isn't inside a repo. Forward slashes throughout
/// for stable string comparison on Windows.
/// </summary>
public static async Task<string?> GetRepoRootAsync(string folderPath)
{
if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath))
return null;
try
{
string? commonDir = await RunGitAsync(folderPath, "rev-parse --git-common-dir");
if (string.IsNullOrWhiteSpace(commonDir)) return null;
string trimmed = commonDir.Trim();

// git may return a path relative to the cwd (e.g. ".git" for a plain repo)
// or an absolute path (e.g. "C:/repo/.git" when called from a worktree).
// Resolve to an absolute path either way.
string absolute = System.IO.Path.IsPathRooted(trimmed)
? trimmed
: System.IO.Path.GetFullPath(trimmed, folderPath);

// Strip the trailing ".git" segment to get the repo's working tree root.
string normalized = absolute.Replace('\\', '/').TrimEnd('/');
if (normalized.EndsWith("/.git", StringComparison.OrdinalIgnoreCase))
normalized = normalized[..^"/.git".Length];
else if (normalized.EndsWith(".git", StringComparison.OrdinalIgnoreCase)
&& !normalized.EndsWith("/.git", StringComparison.OrdinalIgnoreCase))
normalized = normalized[..^".git".Length].TrimEnd('/');

return string.IsNullOrEmpty(normalized) ? null : normalized;
}
catch { return null; }
}

/// <summary>Describes one git worktree as reported by `git worktree list --porcelain`.</summary>
public record WorktreeInfo(string Path, string? Branch, bool IsBare, bool IsDetached, bool IsLocked, bool IsPrunable);

/// <summary>
/// Returns all worktrees (including the main one) of the repo containing
/// <paramref name="folderPath"/>. Empty if the folder isn't in a repo.
/// </summary>
public static async Task<IReadOnlyList<WorktreeInfo>> ListWorktreesAsync(string folderPath)
{
if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath))
return Array.Empty<WorktreeInfo>();
try
{
string? raw = await RunGitAsync(folderPath, "worktree list --porcelain");
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<WorktreeInfo>();

// Output is blank-line separated stanzas:
// worktree /path
// HEAD <sha>
// branch refs/heads/<name> (or "detached", "bare")
// locked [reason]
// prunable [reason]
var results = new List<WorktreeInfo>();
string? path = null;
string? branch = null;
bool isBare = false, isDetached = false, isLocked = false, isPrunable = false;
void Flush()
{
if (!string.IsNullOrEmpty(path))
results.Add(new WorktreeInfo(path, branch, isBare, isDetached, isLocked, isPrunable));
path = null; branch = null; isBare = false; isDetached = false; isLocked = false; isPrunable = false;
}
foreach (var line in raw.Replace("\r", "").Split('\n'))
{
if (string.IsNullOrEmpty(line)) { Flush(); continue; }
if (line.StartsWith("worktree ")) path = line.Substring("worktree ".Length).Trim();
else if (line.StartsWith("branch ")) branch = line.Substring("branch ".Length).Trim()
.Replace("refs/heads/", "", StringComparison.Ordinal);
else if (line == "bare") isBare = true;
else if (line == "detached") isDetached = true;
else if (line.StartsWith("locked")) isLocked = true;
else if (line.StartsWith("prunable")) isPrunable = true;
}
Flush();
return results;
}
catch { return Array.Empty<WorktreeInfo>(); }
}

/// <summary>Returns local branch names in the repo, oldest-first by git's default order.</summary>
public static async Task<IReadOnlyList<string>> ListBranchesAsync(string folderPath)
{
if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath))
return Array.Empty<string>();
try
{
string? raw = await RunGitAsync(folderPath, "for-each-ref --format=%(refname:short) refs/heads");
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
var lines = raw.Replace("\r", "").Split('\n', StringSplitOptions.RemoveEmptyEntries);
return lines;
}
catch { return Array.Empty<string>(); }
}

/// <summary>
/// Runs `git worktree add` either with a new branch (-b) or pointing at an
/// existing ref. Returns (success, errorOutput).
/// </summary>
public static async Task<(bool ok, string error)> CreateWorktreeAsync(
string repoRoot, string targetPath, string branchOrRef, bool createBranch)
{
if (string.IsNullOrWhiteSpace(repoRoot) || !System.IO.Directory.Exists(repoRoot))
return (false, "Repo root does not exist.");
if (string.IsNullOrWhiteSpace(targetPath))
return (false, "Worktree path is required.");
if (string.IsNullOrWhiteSpace(branchOrRef))
return (false, "Branch is required.");

string args = createBranch
? $"worktree add -b \"{branchOrRef}\" \"{targetPath}\""
: $"worktree add \"{targetPath}\" \"{branchOrRef}\"";

var (output, stderr, exit) = await RunGitFullAsync(repoRoot, args, timeoutMs: 30_000);
if (exit == 0) return (true, "");
string err = string.IsNullOrWhiteSpace(stderr)
? (string.IsNullOrWhiteSpace(output) ? "git worktree add failed." : output)
: stderr;
return (false, err.Trim());
}

private static async Task<string?> RunGitAsync(string workingDir, string arguments)
{
var (stdout, _, exit) = await RunGitFullAsync(workingDir, arguments, timeoutMs: 3000);
return exit == 0 ? stdout : null;
}

private static async Task<(string stdout, string stderr, int exit)> RunGitFullAsync(
string workingDir, string arguments, int timeoutMs)
{
var psi = new ProcessStartInfo("git")
{
Expand All @@ -40,19 +176,21 @@ public static class GitService
};

using var process = Process.Start(psi);
if (process is null)
return null;
if (process is null) return ("", "", -1);

var outputTask = process.StandardOutput.ReadToEndAsync();
var completed = await Task.WhenAny(outputTask, Task.Delay(3000));
var outTask = process.StandardOutput.ReadToEndAsync();
var errTask = process.StandardError.ReadToEndAsync();
var bothTask = Task.WhenAll(outTask, errTask);
var completed = await Task.WhenAny(bothTask, Task.Delay(timeoutMs));

if (completed != outputTask)
if (completed != bothTask)
{
try { process.Kill(); } catch { }
return null;
}
try { await process.WaitForExitAsync(); } catch { }

await process.WaitForExitAsync();
return process.ExitCode == 0 ? await outputTask : null;
string stdout = outTask.IsCompletedSuccessfully ? outTask.Result : "";
string stderr = errTask.IsCompletedSuccessfully ? errTask.Result : "";
return (stdout, stderr, process.HasExited ? process.ExitCode : -1);
}
}
Loading
Loading