Skip to content

Commit 1403640

Browse files
committed
feat(project-tree): add expand/collapse, fix tree node hierarchy and connector lines
- Split project keys by '--' to derive tree hierarchy levels; create virtual intermediate nodes for path segments between a parent project and its worktree (fixes translation-service not appearing as a tree node) - Detect worktree kind from the last '--' segment of the child key, so virtual parent keys (e.g. __virtual:...) work correctly - Add hasChildren and isExpanded fields to FlatItem; flattenTree and buildFlatItems accept a collapsedKeys set to hide children of collapsed nodes - Web: chevron toggle button (▸/▾) at fixed column before node name; Space/→ expands, ← collapses in keyboard sidebar navigation - TUI: same expand/collapse via Space/arrow keys; chevron rendered inline - Replace single ::before guide line with rendered GuideLines spans: one span per ancestor depth level with corrected offsets (border-left accounted) plus a 5px horizontal L-arm on the last span to connect guide to toggle - Toggle arrow enlarged to 13px / 18px button; name span gets flex: 1 for consistent left-aligned text across all depth levels
1 parent 23113d6 commit 1403640

9 files changed

Lines changed: 463 additions & 52 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# GitNexus — Code Intelligence
44

5-
This project is indexed by GitNexus as **tail-claude-gui** (1589 symbols, 3812 relationships, 132 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
5+
This project is indexed by GitNexus as **tail-claude-gui** (1625 symbols, 3908 relationships, 135 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
66

77
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
88

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ npx oxfmt && npx oxlint && npx tsc --noEmit && npx vitest run && cargo fmt --man
3838

3939
# GitNexus — Code Intelligence
4040

41-
This project is indexed by GitNexus as **tail-claude-gui** (1589 symbols, 3812 relationships, 132 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
41+
This project is indexed by GitNexus as **tail-claude-gui** (1625 symbols, 3908 relationships, 135 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
4242

4343
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
4444

shared/projectTree.test.ts

Lines changed: 145 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ import {
88
detectWorktreeKind,
99
worktreeLeafName,
1010
buildFlatItems,
11+
type FlatItem,
1112
} from "./projectTree";
1213

14+
function omitExpandFields(items: FlatItem[]): Omit<FlatItem, "hasChildren" | "isExpanded">[] {
15+
return items.map(({ hasChildren: _h, isExpanded: _e, ...rest }) => rest);
16+
}
17+
1318
function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
1419
return {
1520
path: "/home/user/.claude/projects/my-project/session1.jsonl",
@@ -117,34 +122,87 @@ describe("buildTree", () => {
117122
const roots = buildTree(nodes);
118123
expect(roots).toHaveLength(2);
119124
});
125+
126+
it("creates virtual intermediate node for -- segments between parent and worktree", () => {
127+
const nodes = [
128+
{ name: "sp-03bf9f55", key: "-Users-me--sp-03bf9f55", sessionCount: 1, hasOngoing: false },
129+
{
130+
name: "wt",
131+
key: "-Users-me--sp-03bf9f55-translation-service--claude-worktrees-main",
132+
sessionCount: 1,
133+
hasOngoing: false,
134+
},
135+
];
136+
const roots = buildTree(nodes);
137+
expect(roots).toHaveLength(1);
138+
const parent = roots[0];
139+
expect(parent.node.key).toBe("-Users-me--sp-03bf9f55");
140+
expect(parent.children).toHaveLength(1);
141+
const vn = parent.children[0];
142+
expect(vn.node.name).toBe("translation-service");
143+
expect(vn.node.key).toMatch(/^__virtual:/);
144+
expect(vn.children).toHaveLength(1);
145+
expect(vn.children[0].node.key).toBe(
146+
"-Users-me--sp-03bf9f55-translation-service--claude-worktrees-main",
147+
);
148+
});
149+
150+
it("virtual node aggregates sessionCount and hasOngoing from children", () => {
151+
const nodes = [
152+
{ name: "proj", key: "-Users-me--proj", sessionCount: 1, hasOngoing: false },
153+
{
154+
name: "wt",
155+
key: "-Users-me--proj-svc--claude-worktrees-main",
156+
sessionCount: 2,
157+
hasOngoing: true,
158+
},
159+
];
160+
const roots = buildTree(nodes);
161+
const vn = roots[0].children[0];
162+
expect(vn.node.sessionCount).toBe(2);
163+
expect(vn.node.hasOngoing).toBe(true);
164+
});
120165
});
121166

122167
describe("detectWorktreeKind", () => {
123-
it("detects worktrees", () => {
168+
it("detects worktrees (old single-dash style)", () => {
124169
expect(detectWorktreeKind("-Users-me-backend", "-Users-me-backend-worktrees-EC-123")).toBe(
125170
"worktrees",
126171
);
127172
});
128173

129-
it("detects claude-worktrees", () => {
174+
it("detects claude-worktrees (-- style, last segment)", () => {
130175
expect(detectWorktreeKind("-Users-me-backend", "-Users-me-backend--claude-worktrees-fox")).toBe(
131176
"claude-worktrees",
132177
);
133178
});
134179

180+
it("detects claude-worktrees when intermediate -- segment exists (virtual parent key)", () => {
181+
expect(
182+
detectWorktreeKind(
183+
"__virtual:-Users-me--proj:svc",
184+
"-Users-me--proj-svc--claude-worktrees-main",
185+
),
186+
).toBe("claude-worktrees");
187+
});
188+
135189
it("returns null for non-worktree children", () => {
136190
expect(detectWorktreeKind("-Users-me-backend", "-Users-me-backend-tools")).toBeNull();
137191
});
192+
193+
it("returns null for -- child that is not a worktree", () => {
194+
expect(detectWorktreeKind("-Users-me--proj", "-Users-me--proj--subproject")).toBeNull();
195+
});
138196
});
139197

140198
describe("worktreeLeafName", () => {
141-
it("extracts leaf name for worktrees", () => {
199+
it("extracts leaf name for worktrees (old single-dash style)", () => {
142200
expect(
143201
worktreeLeafName("-Users-me-backend", "-Users-me-backend-worktrees-EC-123", "worktrees"),
144202
).toBe("EC-123");
145203
});
146204

147-
it("extracts leaf name for claude-worktrees", () => {
205+
it("extracts leaf name for claude-worktrees (-- style)", () => {
148206
expect(
149207
worktreeLeafName(
150208
"-Users-me-backend",
@@ -153,6 +211,16 @@ describe("worktreeLeafName", () => {
153211
),
154212
).toBe("happy-crane");
155213
});
214+
215+
it("extracts leaf name via -- segment when virtual parent key is used", () => {
216+
expect(
217+
worktreeLeafName(
218+
"__virtual:-Users-me--proj:svc",
219+
"-Users-me--proj-svc--claude-worktrees-security-rubygems-addressable-133",
220+
"claude-worktrees",
221+
),
222+
).toBe("security-rubygems-addressable-133");
223+
});
156224
});
157225

158226
describe("flattenTree", () => {
@@ -162,14 +230,16 @@ describe("flattenTree", () => {
162230
]);
163231
const flat = flattenTree(roots);
164232
expect(flat).toHaveLength(1);
165-
expect(flat[0]).toEqual({
233+
expect(omitExpandFields(flat)[0]).toEqual({
166234
key: "-Users-me-backend",
167235
name: "backend",
168236
count: 2,
169237
ongoing: true,
170238
depth: 0,
171239
isGroup: false,
172240
});
241+
expect(flat[0].hasChildren).toBe(false);
242+
expect(flat[0].isExpanded).toBe(true);
173243
});
174244

175245
it("creates worktree group nodes", () => {
@@ -186,11 +256,78 @@ describe("flattenTree", () => {
186256
const flat = flattenTree(roots);
187257
// parent, group header, leaf
188258
expect(flat).toHaveLength(3);
259+
expect(flat[0].hasChildren).toBe(true);
260+
expect(flat[0].isExpanded).toBe(true);
189261
expect(flat[1].isGroup).toBe(true);
190262
expect(flat[1].name).toBe("worktrees");
263+
expect(flat[1].hasChildren).toBe(true);
191264
expect(flat[2].name).toBe("EC-123");
192265
expect(flat[2].depth).toBe(2);
193266
});
267+
268+
it("hides children of a collapsed project node", () => {
269+
const nodes = [
270+
{ name: "backend", key: "-Users-me-backend", sessionCount: 1, hasOngoing: false },
271+
{
272+
name: "EC-123",
273+
key: "-Users-me-backend-worktrees-EC-123",
274+
sessionCount: 1,
275+
hasOngoing: false,
276+
},
277+
];
278+
const collapsed = new Set(["-Users-me-backend"]);
279+
const flat = flattenTree(buildTree(nodes), collapsed);
280+
// only the parent; children hidden
281+
expect(flat).toHaveLength(1);
282+
expect(flat[0].isExpanded).toBe(false);
283+
expect(flat[0].hasChildren).toBe(true);
284+
});
285+
286+
it("hides children of a collapsed group header", () => {
287+
const nodes = [
288+
{ name: "backend", key: "-Users-me-backend", sessionCount: 1, hasOngoing: false },
289+
{
290+
name: "EC-123",
291+
key: "-Users-me-backend-worktrees-EC-123",
292+
sessionCount: 1,
293+
hasOngoing: false,
294+
},
295+
];
296+
const collapsed = new Set(["__group:worktrees:-Users-me-backend"]);
297+
const flat = flattenTree(buildTree(nodes), collapsed);
298+
// parent + group header only; leaf hidden
299+
expect(flat).toHaveLength(2);
300+
expect(flat[1].isGroup).toBe(true);
301+
expect(flat[1].isExpanded).toBe(false);
302+
});
303+
304+
it("shows virtual intermediate node between parent and worktree group", () => {
305+
const nodes = [
306+
{ name: "sp-03bf9f55", key: "-Users-me--sp-03bf9f55", sessionCount: 1, hasOngoing: false },
307+
{
308+
name: "wt",
309+
key: "-Users-me--sp-03bf9f55-translation-service--claude-worktrees-security-rubygems-addressable-133",
310+
sessionCount: 1,
311+
hasOngoing: false,
312+
},
313+
];
314+
const flat = flattenTree(buildTree(nodes));
315+
// depth 0: sp-03bf9f55
316+
// depth 1: translation-service (virtual)
317+
// depth 2: claude-worktrees (group)
318+
// depth 3: security-rubygems-addressable-133 (leaf)
319+
expect(flat).toHaveLength(4);
320+
expect(flat[0].name).toBe("sp-03bf9f55");
321+
expect(flat[0].depth).toBe(0);
322+
expect(flat[1].name).toBe("translation-service");
323+
expect(flat[1].depth).toBe(1);
324+
expect(flat[1].isGroup).toBe(false);
325+
expect(flat[2].name).toBe("claude-worktrees");
326+
expect(flat[2].isGroup).toBe(true);
327+
expect(flat[2].depth).toBe(2);
328+
expect(flat[3].name).toBe("security-rubygems-addressable-133");
329+
expect(flat[3].depth).toBe(3);
330+
});
194331
});
195332

196333
describe("buildFlatItems", () => {
@@ -199,14 +336,16 @@ describe("buildFlatItems", () => {
199336
makeSession({ path: "/home/user/.claude/projects/proj-a/s1.jsonl", cwd: "/x/proj-a" }),
200337
];
201338
const items = buildFlatItems(sessions);
202-
expect(items[0]).toEqual({
339+
expect(omitExpandFields(items)[0]).toEqual({
203340
key: null,
204341
name: "All Projects",
205342
count: 1,
206343
ongoing: false,
207344
depth: 0,
208345
isGroup: false,
209346
});
347+
expect(items[0].hasChildren).toBe(false);
348+
expect(items[0].isExpanded).toBe(true);
210349
});
211350

212351
it("returns correct total count in All Projects", () => {

0 commit comments

Comments
 (0)