Skip to content

Commit eeae67f

Browse files
authored
Merge pull request #1 from OmryB-datrails/feature/project-filter
feat: add project filter and fix project name decoding
2 parents fb7a65d + 5a5b746 commit eeae67f

File tree

9 files changed

+795
-11
lines changed

9 files changed

+795
-11
lines changed

docs/plans/project-filter-spec.md

Lines changed: 485 additions & 0 deletions
Large diffs are not rendered by default.

src/__tests__/watcher.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { SessionWatcher } from '../watcher.js';
3+
import type { SessionData } from '../types.js';
4+
5+
function mockSession(overrides: Partial<SessionData> = {}): SessionData {
6+
return {
7+
id: 'test-id',
8+
filePath: '/tmp/test.jsonl',
9+
project: 'default-project',
10+
source: 'claude-code',
11+
model: 'claude-sonnet-4-20250514',
12+
cwd: '/tmp',
13+
startedAt: new Date(),
14+
isActive: false,
15+
tokens: { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 },
16+
compactions: 0,
17+
toolCalls: {},
18+
agentSpawns: 0,
19+
skillInvocations: [],
20+
subagents: [],
21+
agentDescriptions: [],
22+
...overrides,
23+
};
24+
}
25+
26+
function createWatcherWithSessions(
27+
sessions: SessionData[]
28+
): SessionWatcher {
29+
const watcher = new SessionWatcher(['/tmp'], false);
30+
const map = (watcher as any).sessions as Map<string, SessionData>;
31+
for (const s of sessions) {
32+
map.set(s.id, s);
33+
}
34+
return watcher;
35+
}
36+
37+
describe('SessionWatcher.getProjects', () => {
38+
it('returns unique sorted project names from loaded sessions', () => {
39+
const watcher = createWatcherWithSessions([
40+
mockSession({ id: 's1', project: 'proj-b' }),
41+
mockSession({ id: 's2', project: 'proj-a' }),
42+
mockSession({ id: 's3', project: 'proj-b' }),
43+
]);
44+
45+
expect(watcher.getProjects()).toEqual(['proj-a', 'proj-b']);
46+
});
47+
48+
it('returns empty array when no sessions loaded', () => {
49+
const watcher = new SessionWatcher(['/tmp'], false);
50+
expect(watcher.getProjects()).toEqual([]);
51+
});
52+
53+
it('handles sessions with empty/undefined project', () => {
54+
const watcher = createWatcherWithSessions([
55+
mockSession({ id: 's1', project: '' }),
56+
mockSession({ id: 's2', project: 'proj-a' }),
57+
]);
58+
59+
// Empty string is falsy, so getProjects() skips it
60+
expect(watcher.getProjects()).toEqual(['proj-a']);
61+
});
62+
});
63+
64+
describe('SessionWatcher.getSessions with projectFilter', () => {
65+
it('returns all sessions when no projectFilter is given (backward compat)', () => {
66+
const now = new Date();
67+
const watcher = createWatcherWithSessions([
68+
mockSession({ id: 's1', project: 'proj-a', startedAt: now }),
69+
mockSession({ id: 's2', project: 'proj-b', startedAt: now }),
70+
]);
71+
72+
expect(watcher.getSessions(false)).toHaveLength(2);
73+
});
74+
75+
it('filters sessions by project name', () => {
76+
const now = new Date();
77+
const watcher = createWatcherWithSessions([
78+
mockSession({ id: 's1', project: 'proj-a', startedAt: now }),
79+
mockSession({ id: 's2', project: 'proj-b', startedAt: now }),
80+
mockSession({ id: 's3', project: 'proj-a', startedAt: now }),
81+
]);
82+
83+
const filtered = watcher.getSessions(false, 'proj-a');
84+
expect(filtered).toHaveLength(2);
85+
expect(filtered.every(s => s.project === 'proj-a')).toBe(true);
86+
});
87+
88+
it('combines activeOnly and projectFilter', () => {
89+
const now = new Date();
90+
const watcher = createWatcherWithSessions([
91+
mockSession({ id: 's1', project: 'proj-a', isActive: true, startedAt: now }),
92+
mockSession({ id: 's2', project: 'proj-a', isActive: false, startedAt: now }),
93+
mockSession({ id: 's3', project: 'proj-b', isActive: true, startedAt: now }),
94+
]);
95+
96+
const filtered = watcher.getSessions(true, 'proj-a');
97+
expect(filtered).toHaveLength(1);
98+
expect(filtered[0].id).toBe('s1');
99+
});
100+
101+
it('returns empty when projectFilter matches no sessions', () => {
102+
const watcher = createWatcherWithSessions([
103+
mockSession({ id: 's1', project: 'proj-a', startedAt: new Date() }),
104+
]);
105+
106+
const filtered = watcher.getSessions(false, 'nonexistent');
107+
expect(filtered).toHaveLength(0);
108+
});
109+
110+
it('sorts sessions by startedAt descending', () => {
111+
const t1 = new Date('2026-03-10T10:00:00Z');
112+
const t2 = new Date('2026-03-11T10:00:00Z');
113+
const t3 = new Date('2026-03-12T10:00:00Z');
114+
const watcher = createWatcherWithSessions([
115+
mockSession({ id: 's1', project: 'proj-a', startedAt: t1 }),
116+
mockSession({ id: 's2', project: 'proj-a', startedAt: t3 }),
117+
mockSession({ id: 's3', project: 'proj-a', startedAt: t2 }),
118+
]);
119+
120+
const result = watcher.getSessions(false, 'proj-a');
121+
expect(result.map(s => s.id)).toEqual(['s2', 's3', 's1']);
122+
});
123+
});

src/discovery.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,59 @@
11
import { readdir, stat, readFile } from 'fs/promises';
2+
import { existsSync } from 'fs';
23
import { join, basename } from 'path';
34
import { homedir } from 'os';
45
import type { ProjectInfo, SessionData } from './types.js';
56
import { parseSessionFile } from './parser.js';
67

8+
/**
9+
* Decode a Claude Code project directory name back to the real filesystem path.
10+
*
11+
* Encoding rules used by Claude Code:
12+
* / → -
13+
* /.<hidden> → --<hidden> (e.g. /.claude → --claude)
14+
*
15+
* Since the encoding is lossy (a literal dash in a dir name looks the same as a
16+
* path separator), we resolve ambiguity greedily: at each step, try the shortest
17+
* candidate segment that exists as a real directory on disk.
18+
*/
19+
function decodeDirName(encoded: string): string {
20+
// Restore hidden-dir dots: "--foo" in the middle means "/.<foo>"
21+
// First, replace leading "-" with "/" to get the absolute prefix,
22+
// then replace remaining "--" with "/-." (hidden dir marker).
23+
const normalized = encoded
24+
.replace(/^-/, '/')
25+
.replace(/--/g, '/.');
26+
27+
const segments = normalized.substring(1).split('-'); // drop leading /
28+
if (segments.length === 0) return '/';
29+
30+
let resolved = '/';
31+
let buffer = segments[0];
32+
33+
for (let i = 1; i < segments.length; i++) {
34+
const testDir = join(resolved, buffer);
35+
if (existsSync(testDir)) {
36+
// The buffer matches a real directory → treat the dash as a path separator
37+
resolved = testDir;
38+
buffer = segments[i];
39+
} else {
40+
// Not a directory → the dash was literal, keep accumulating
41+
buffer += '-' + segments[i];
42+
}
43+
}
44+
45+
return join(resolved, buffer);
46+
}
47+
48+
/** Turn a decoded absolute path into a display-friendly name (~/…) */
49+
function toDisplayName(absolutePath: string): string {
50+
const home = homedir();
51+
if (absolutePath.startsWith(home)) {
52+
return '~' + absolutePath.substring(home.length);
53+
}
54+
return absolutePath;
55+
}
56+
757
export function getClaudeHome(): string {
858
return join(homedir(), '.claude');
959
}
@@ -24,7 +74,7 @@ export async function discoverProjects(claudeHome?: string): Promise<ProjectInfo
2474
const projPath = join(projectsDir, entry.name);
2575
const jsonlFiles = (await readdir(projPath)).filter(f => f.endsWith('.jsonl'));
2676
projects.push({
27-
name: entry.name.replace(/-/g, '/').replace(/^\//, ''),
77+
name: toDisplayName(decodeDirName(entry.name)),
2878
path: projPath,
2979
sessionCount: jsonlFiles.length,
3080
});
@@ -39,7 +89,7 @@ export async function discoverProjects(claudeHome?: string): Promise<ProjectInfo
3989
export async function discoverSessions(projectPath: string): Promise<SessionData[]> {
4090
const entries = await readdir(projectPath, { withFileTypes: true });
4191
const sessions: SessionData[] = [];
42-
const projectName = basename(projectPath);
92+
const projectName = toDisplayName(decodeDirName(basename(projectPath)));
4393

4494
for (const entry of entries) {
4595
if (entry.isFile() && entry.name.endsWith('.jsonl')) {

src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ program
5555
.option('--port <number>', 'Web server port', '3000')
5656
.option('--project <path>', 'Project directory to monitor')
5757
.option('--all', 'Monitor all projects (default for web mode)')
58+
.option('--filter-project <name>', 'Filter sessions to a specific project name')
5859
.action(async (options) => {
5960
// Default to --all unless a specific project is given
6061
if (!options.project) {
@@ -72,10 +73,10 @@ program
7273
await watcher.start();
7374

7475
if (options.tui) {
75-
startApp(watcher);
76+
startApp(watcher, { initialProjectFilter: options.filterProject });
7677
} else {
7778
const port = parseInt(options.port, 10);
78-
createWebServer(watcher, port);
79+
createWebServer(watcher, port, { initialProjectFilter: options.filterProject });
7980
}
8081
});
8182

src/tui/app.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { createDetailPanel } from './detail.js';
44
import type { SessionWatcher } from '../watcher.js';
55
import type { SessionData } from '../types.js';
66

7-
export function startApp(watcher: SessionWatcher): void {
7+
export function startApp(
8+
watcher: SessionWatcher,
9+
options?: { initialProjectFilter?: string }
10+
): void {
811
const screen = blessed.screen({
912
smartCSR: true,
1013
title: 'claude-watch',
@@ -28,14 +31,16 @@ export function startApp(watcher: SessionWatcher): void {
2831
let sessions: SessionData[] = [];
2932
let selectedIndex = 0;
3033
let activeOnly = true;
34+
let projectFilter: string | undefined = options?.initialProjectFilter;
3135

3236
function updateStatus() {
3337
const filterLabel = activeOnly ? 'active' : 'all';
38+
const projectLabel = projectFilter || 'all projects';
3439
const count = sessions.length;
3540
const activeCount = sessions.filter(s => s.isActive).length;
3641
statusBar.setContent(
37-
` ${count} sessions (${activeCount} active) [${filterLabel}]` +
38-
` | j/k:navigate a:toggle filter r:refresh q:quit`
42+
` ${count} sessions (${activeCount} active) [${filterLabel}] [${projectLabel}]` +
43+
` | j/k:navigate a:toggle filter p:project r:refresh q:quit`
3944
);
4045
}
4146

@@ -47,7 +52,7 @@ export function startApp(watcher: SessionWatcher): void {
4752
}
4853

4954
function refresh() {
50-
sessions = watcher.getSessions(activeOnly);
55+
sessions = watcher.getSessions(activeOnly, projectFilter);
5156
if (selectedIndex >= sessions.length) {
5257
selectedIndex = Math.max(0, sessions.length - 1);
5358
}
@@ -90,6 +95,24 @@ export function startApp(watcher: SessionWatcher): void {
9095
refresh();
9196
});
9297

98+
screen.key(['p'], () => {
99+
const projects = watcher.getProjects();
100+
if (projects.length === 0) return;
101+
102+
if (!projectFilter) {
103+
projectFilter = projects[0];
104+
} else {
105+
const idx = projects.indexOf(projectFilter);
106+
if (idx === -1 || idx === projects.length - 1) {
107+
projectFilter = undefined;
108+
} else {
109+
projectFilter = projects[idx + 1];
110+
}
111+
}
112+
selectedIndex = 0;
113+
refresh();
114+
});
115+
93116
// Initial render
94117
refresh();
95118
}

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ export interface SessionDetail {
192192
export interface SessionSummary {
193193
id: string;
194194
filePath: string;
195+
project: string;
195196
source: SessionSource;
196197
title?: string;
197198
model: string;

src/watcher.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,25 @@ export class SessionWatcher extends EventEmitter {
108108
}
109109
}
110110

111-
getSessions(activeOnly: boolean = false): SessionData[] {
111+
/** Returns unique project names from all loaded sessions, sorted alphabetically. */
112+
getProjects(): string[] {
113+
const projects = new Set<string>();
114+
for (const session of this.sessions.values()) {
115+
if (session.project) {
116+
projects.add(session.project);
117+
}
118+
}
119+
return [...projects].sort();
120+
}
121+
122+
getSessions(activeOnly: boolean = false, projectFilter?: string): SessionData[] {
112123
let sessions = Array.from(this.sessions.values());
113124
if (activeOnly) {
114125
sessions = sessions.filter(s => s.isActive);
115126
}
127+
if (projectFilter) {
128+
sessions = sessions.filter(s => s.project === projectFilter);
129+
}
116130
return sessions.sort((a, b) => {
117131
if (!a.startedAt || !b.startedAt) return 0;
118132
return b.startedAt.getTime() - a.startedAt.getTime();

0 commit comments

Comments
 (0)