Skip to content

Commit 4ed7d36

Browse files
authored
Merge pull request #28 from umage-ai/feat/session-groups-worktrees
feat: session groups + per-branch worktrees
2 parents e158947 + 1be61cd commit 4ed7d36

15 files changed

Lines changed: 2633 additions & 82 deletions

src/CodeShellManager/MainWindow.xaml

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -228,23 +228,49 @@
228228
</Border>
229229

230230
<!-- ── Sidebar ──────────────────────────────────────────────────── -->
231-
<Border DockPanel.Dock="Left" Width="230" Background="#181825"
231+
<Border DockPanel.Dock="Left" Background="#181825"
232232
BorderThickness="0,0,1,0" BorderBrush="#313244">
233-
<DockPanel>
234-
<Border DockPanel.Dock="Top" Padding="12,8,8,8" BorderThickness="0,0,0,1"
235-
BorderBrush="#313244">
236-
<DockPanel>
237-
<TextBlock Text="SESSIONS" Foreground="#6c7086" FontSize="10"
238-
FontWeight="Bold" VerticalAlignment="Center"/>
239-
<Button DockPanel.Dock="Right" Content="+" Style="{StaticResource ToolBtn}"
240-
ToolTip="New Session (Ctrl+T)" Click="NewSession_Click"
241-
Width="24" Height="24" FontSize="16" Foreground="#a6e3a1"/>
242-
</DockPanel>
233+
<Grid>
234+
<Grid.ColumnDefinitions>
235+
<ColumnDefinition x:Name="GroupStripCol" Width="0"/>
236+
<ColumnDefinition Width="230"/>
237+
</Grid.ColumnDefinitions>
238+
239+
<!-- Group tab strip (vertical column of category tabs).
240+
Hidden by default; MainWindow toggles GroupStripCol width on/off. -->
241+
<Border Grid.Column="0" x:Name="GroupStripBorder"
242+
Background="#11111b" BorderThickness="0,0,1,0"
243+
BorderBrush="#313244" Visibility="Collapsed">
244+
<ScrollViewer VerticalScrollBarVisibility="Auto"
245+
HorizontalScrollBarVisibility="Disabled">
246+
<StackPanel x:Name="GroupStripPanel"
247+
AutomationProperties.AutomationId="GroupStripPanel"
248+
Margin="0,6,0,6"/>
249+
</ScrollViewer>
243250
</Border>
244-
<ScrollViewer VerticalScrollBarVisibility="Auto">
245-
<StackPanel x:Name="SidebarSessionList" AutomationProperties.AutomationId="SidebarSessionList" Margin="6,6"/>
246-
</ScrollViewer>
247-
</DockPanel>
251+
252+
<DockPanel Grid.Column="1">
253+
<Border DockPanel.Dock="Top" Padding="12,8,8,8" BorderThickness="0,0,0,1"
254+
BorderBrush="#313244">
255+
<Grid>
256+
<Grid.ColumnDefinitions>
257+
<ColumnDefinition Width="*"/>
258+
<ColumnDefinition Width="Auto"/>
259+
</Grid.ColumnDefinitions>
260+
<TextBlock Grid.Column="0" Text="SESSIONS" Foreground="#6c7086" FontSize="10"
261+
FontWeight="Bold" VerticalAlignment="Center"/>
262+
<Button Grid.Column="1" Content="+" Style="{StaticResource ToolBtn}"
263+
ToolTip="New Session (Ctrl+T)" Click="NewSession_Click"
264+
Width="26" Height="24" FontSize="16" Foreground="#a6e3a1"
265+
VerticalAlignment="Center" Margin="0,0,2,0"
266+
Padding="0,0,0,2"/>
267+
</Grid>
268+
</Border>
269+
<ScrollViewer VerticalScrollBarVisibility="Auto">
270+
<StackPanel x:Name="SidebarSessionList" AutomationProperties.AutomationId="SidebarSessionList" Margin="6,6"/>
271+
</ScrollViewer>
272+
</DockPanel>
273+
</Grid>
248274
</Border>
249275

250276
<!-- ── Terminal grid ────────────────────────────────────────────── -->

src/CodeShellManager/MainWindow.xaml.cs

Lines changed: 1561 additions & 37 deletions
Large diffs are not rendered by default.

src/CodeShellManager/Models/AppState.cs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
namespace CodeShellManager.Models;
44

5+
/// <summary>
6+
/// How the sidebar surfaces session groups.
7+
/// None = no group UI at all (flat session list). FilterStrip = vertical tab strip
8+
/// to the left of the sidebar, one filter at a time (default). InlineHeaders =
9+
/// collapsible group headers inline in the sidebar, all groups visible at once.
10+
/// </summary>
11+
public enum GroupDisplayMode
12+
{
13+
None,
14+
FilterStrip,
15+
InlineHeaders
16+
}
17+
518
public class AppSettings
619
{
720
public bool AutoRestoreSessions { get; set; } = true;
@@ -13,6 +26,34 @@ public class AppSettings
1326
public string DefaultCommand { get; set; } = "claude";
1427
public string DefaultWorkingFolder { get; set; } = "";
1528
public bool ShowGitBranch { get; set; } = true;
29+
/// <summary>Authoritative grouping UI selector. Replaces the legacy <see cref="ShowGroupsTab"/> boolean.</summary>
30+
public GroupDisplayMode GroupDisplayMode { get; set; } = GroupDisplayMode.FilterStrip;
31+
/// <summary>
32+
/// Legacy flag — kept for back-compat with older state.json files. When deserialized
33+
/// as false on a state that still has GroupDisplayMode at its default, the loader
34+
/// migrates the mode to None. Newer code paths read GroupDisplayMode instead.
35+
/// </summary>
36+
public bool ShowGroupsTab { get; set; } = true;
37+
/// <summary>
38+
/// When 2+ adjacent visible sessions share a repo root, draw a small header above
39+
/// them ("📁 repoName (N)") to make the worktree grouping obvious. Off = the
40+
/// implicit subtitle + shared stripe color are the only signals.
41+
/// </summary>
42+
public bool ShowWorktreeClusters { get; set; } = true;
43+
/// <summary>
44+
/// Expand/collapse state of the implicit Ungrouped header in InlineHeaders mode.
45+
/// Real groups carry their own <see cref="SessionGroup.IsExpanded"/> bit; this holds
46+
/// the equivalent for the Ungrouped pseudo-section so it persists across restarts.
47+
/// </summary>
48+
public bool UngroupedSectionExpanded { get; set; } = true;
49+
/// <summary>
50+
/// One-shot guard for the legacy auto-created "Default" group migration in
51+
/// <see cref="Services.SessionManager.LoadFromState"/>. Without this gate the
52+
/// heuristic (single group, name "Default", SortOrder 0) could wipe a user-named
53+
/// "Default" group on a later restart. Flipped to true after the first load
54+
/// regardless of whether the heuristic matched.
55+
/// </summary>
56+
public bool LegacyDefaultGroupCleared { get; set; } = false;
1657
public bool SearchCollapseAfterNavigate { get; set; } = true;
1758
public string Theme { get; set; } = "dark";
1859
public int MaxSearchResults { get; set; } = 100;
@@ -55,7 +96,7 @@ public class WindowBounds
5596
public class AppState
5697
{
5798
public List<ShellSession> Sessions { get; set; } = [];
58-
public List<SessionGroup> Groups { get; set; } = [new SessionGroup { Name = "Default" }];
99+
public List<SessionGroup> Groups { get; set; } = [];
59100
public string LastLayout { get; set; } = "Single";
60101
public AppSettings Settings { get; set; } = new();
61102

src/CodeShellManager/Services/GitService.cs

Lines changed: 146 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Diagnostics;
34
using System.Threading.Tasks;
45

@@ -28,7 +29,142 @@ public static class GitService
2829
}
2930
}
3031

32+
/// <summary>
33+
/// Returns the canonical "repo identity" path — the parent of the shared .git
34+
/// directory (`git rev-parse --git-common-dir`). This is identical for every
35+
/// worktree of the same repo, so it's safe to use as a sibling-detection key.
36+
/// (`--show-toplevel` would return each worktree's own folder, missing siblings.)
37+
/// Returns null if folderPath isn't inside a repo. Forward slashes throughout
38+
/// for stable string comparison on Windows.
39+
/// </summary>
40+
public static async Task<string?> GetRepoRootAsync(string folderPath)
41+
{
42+
if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath))
43+
return null;
44+
try
45+
{
46+
string? commonDir = await RunGitAsync(folderPath, "rev-parse --git-common-dir");
47+
if (string.IsNullOrWhiteSpace(commonDir)) return null;
48+
string trimmed = commonDir.Trim();
49+
50+
// git may return a path relative to the cwd (e.g. ".git" for a plain repo)
51+
// or an absolute path (e.g. "C:/repo/.git" when called from a worktree).
52+
// Resolve to an absolute path either way.
53+
string absolute = System.IO.Path.IsPathRooted(trimmed)
54+
? trimmed
55+
: System.IO.Path.GetFullPath(trimmed, folderPath);
56+
57+
// Strip the trailing ".git" segment to get the repo's working tree root.
58+
string normalized = absolute.Replace('\\', '/').TrimEnd('/');
59+
if (normalized.EndsWith("/.git", StringComparison.OrdinalIgnoreCase))
60+
normalized = normalized[..^"/.git".Length];
61+
else if (normalized.EndsWith(".git", StringComparison.OrdinalIgnoreCase)
62+
&& !normalized.EndsWith("/.git", StringComparison.OrdinalIgnoreCase))
63+
normalized = normalized[..^".git".Length].TrimEnd('/');
64+
65+
return string.IsNullOrEmpty(normalized) ? null : normalized;
66+
}
67+
catch { return null; }
68+
}
69+
70+
/// <summary>Describes one git worktree as reported by `git worktree list --porcelain`.</summary>
71+
public record WorktreeInfo(string Path, string? Branch, bool IsBare, bool IsDetached, bool IsLocked, bool IsPrunable);
72+
73+
/// <summary>
74+
/// Returns all worktrees (including the main one) of the repo containing
75+
/// <paramref name="folderPath"/>. Empty if the folder isn't in a repo.
76+
/// </summary>
77+
public static async Task<IReadOnlyList<WorktreeInfo>> ListWorktreesAsync(string folderPath)
78+
{
79+
if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath))
80+
return Array.Empty<WorktreeInfo>();
81+
try
82+
{
83+
string? raw = await RunGitAsync(folderPath, "worktree list --porcelain");
84+
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<WorktreeInfo>();
85+
86+
// Output is blank-line separated stanzas:
87+
// worktree /path
88+
// HEAD <sha>
89+
// branch refs/heads/<name> (or "detached", "bare")
90+
// locked [reason]
91+
// prunable [reason]
92+
var results = new List<WorktreeInfo>();
93+
string? path = null;
94+
string? branch = null;
95+
bool isBare = false, isDetached = false, isLocked = false, isPrunable = false;
96+
void Flush()
97+
{
98+
if (!string.IsNullOrEmpty(path))
99+
results.Add(new WorktreeInfo(path, branch, isBare, isDetached, isLocked, isPrunable));
100+
path = null; branch = null; isBare = false; isDetached = false; isLocked = false; isPrunable = false;
101+
}
102+
foreach (var line in raw.Replace("\r", "").Split('\n'))
103+
{
104+
if (string.IsNullOrEmpty(line)) { Flush(); continue; }
105+
if (line.StartsWith("worktree ")) path = line.Substring("worktree ".Length).Trim();
106+
else if (line.StartsWith("branch ")) branch = line.Substring("branch ".Length).Trim()
107+
.Replace("refs/heads/", "", StringComparison.Ordinal);
108+
else if (line == "bare") isBare = true;
109+
else if (line == "detached") isDetached = true;
110+
else if (line.StartsWith("locked")) isLocked = true;
111+
else if (line.StartsWith("prunable")) isPrunable = true;
112+
}
113+
Flush();
114+
return results;
115+
}
116+
catch { return Array.Empty<WorktreeInfo>(); }
117+
}
118+
119+
/// <summary>Returns local branch names in the repo, oldest-first by git's default order.</summary>
120+
public static async Task<IReadOnlyList<string>> ListBranchesAsync(string folderPath)
121+
{
122+
if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath))
123+
return Array.Empty<string>();
124+
try
125+
{
126+
string? raw = await RunGitAsync(folderPath, "for-each-ref --format=%(refname:short) refs/heads");
127+
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
128+
var lines = raw.Replace("\r", "").Split('\n', StringSplitOptions.RemoveEmptyEntries);
129+
return lines;
130+
}
131+
catch { return Array.Empty<string>(); }
132+
}
133+
134+
/// <summary>
135+
/// Runs `git worktree add` either with a new branch (-b) or pointing at an
136+
/// existing ref. Returns (success, errorOutput).
137+
/// </summary>
138+
public static async Task<(bool ok, string error)> CreateWorktreeAsync(
139+
string repoRoot, string targetPath, string branchOrRef, bool createBranch)
140+
{
141+
if (string.IsNullOrWhiteSpace(repoRoot) || !System.IO.Directory.Exists(repoRoot))
142+
return (false, "Repo root does not exist.");
143+
if (string.IsNullOrWhiteSpace(targetPath))
144+
return (false, "Worktree path is required.");
145+
if (string.IsNullOrWhiteSpace(branchOrRef))
146+
return (false, "Branch is required.");
147+
148+
string args = createBranch
149+
? $"worktree add -b \"{branchOrRef}\" \"{targetPath}\""
150+
: $"worktree add \"{targetPath}\" \"{branchOrRef}\"";
151+
152+
var (output, stderr, exit) = await RunGitFullAsync(repoRoot, args, timeoutMs: 30_000);
153+
if (exit == 0) return (true, "");
154+
string err = string.IsNullOrWhiteSpace(stderr)
155+
? (string.IsNullOrWhiteSpace(output) ? "git worktree add failed." : output)
156+
: stderr;
157+
return (false, err.Trim());
158+
}
159+
31160
private static async Task<string?> RunGitAsync(string workingDir, string arguments)
161+
{
162+
var (stdout, _, exit) = await RunGitFullAsync(workingDir, arguments, timeoutMs: 3000);
163+
return exit == 0 ? stdout : null;
164+
}
165+
166+
private static async Task<(string stdout, string stderr, int exit)> RunGitFullAsync(
167+
string workingDir, string arguments, int timeoutMs)
32168
{
33169
var psi = new ProcessStartInfo("git")
34170
{
@@ -40,19 +176,21 @@ public static class GitService
40176
};
41177

42178
using var process = Process.Start(psi);
43-
if (process is null)
44-
return null;
179+
if (process is null) return ("", "", -1);
45180

46-
var outputTask = process.StandardOutput.ReadToEndAsync();
47-
var completed = await Task.WhenAny(outputTask, Task.Delay(3000));
181+
var outTask = process.StandardOutput.ReadToEndAsync();
182+
var errTask = process.StandardError.ReadToEndAsync();
183+
var bothTask = Task.WhenAll(outTask, errTask);
184+
var completed = await Task.WhenAny(bothTask, Task.Delay(timeoutMs));
48185

49-
if (completed != outputTask)
186+
if (completed != bothTask)
50187
{
51188
try { process.Kill(); } catch { }
52-
return null;
53189
}
190+
try { await process.WaitForExitAsync(); } catch { }
54191

55-
await process.WaitForExitAsync();
56-
return process.ExitCode == 0 ? await outputTask : null;
192+
string stdout = outTask.IsCompletedSuccessfully ? outTask.Result : "";
193+
string stderr = errTask.IsCompletedSuccessfully ? errTask.Result : "";
194+
return (stdout, stderr, process.HasExited ? process.ExitCode : -1);
57195
}
58196
}

0 commit comments

Comments
 (0)