1241 lines · 41.7 KB · TypeScript
RawDownload
/** * ProjectScanner service - Scans ~/.claude/projects/ directory and lists all projects. * * Responsibilities: * - Read project directories from ~/.claude/projects/ * - Decode directory names to original paths (with cwd fallback) * - List session files for each project * - Read task list data from ~/.claude/todos/ * - Return sorted list of projects by recent activity * * Delegates to specialized services: * - SessionContentFilter: Noise detection and message filtering * - WorktreeGrouper: Git repository grouping * - SubagentLocator: Subagent file lookup * - SessionSearcher: Search functionality */ import {  type PaginatedSessionsResult,  type Project,  type RepositoryGroup,  type SearchSessionsResult,  type Session,  type SessionCursor,  type SessionMetadataLevel,  type SessionsByIdsOptions,  type SessionsPaginationOptions,} from '@main/types';import {  analyzeSessionFileMetadata,  extractCwd,  extractFirstUserMessagePreview,} from '@main/utils/jsonl';import {  buildSessionPath,  buildSubagentsPath,  buildTodoPath,  extractBaseDir,  extractProjectName,  extractSessionId,  getProjectsBasePath,  getTodosBasePath,  isValidEncodedPath,} from '@main/utils/pathDecoder';import { createLogger } from '@shared/utils/logger';import * as path from 'path'; import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; import { ProjectPathResolver } from './ProjectPathResolver';import { SessionContentFilter } from './SessionContentFilter';import { SessionSearcher } from './SessionSearcher';import { SubagentLocator } from './SubagentLocator';import { subprojectRegistry } from './SubprojectRegistry';import { WorktreeGrouper } from './WorktreeGrouper'; import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemProvider'; const logger = createLogger('Discovery:ProjectScanner'); export class ProjectScanner {  private readonly projectsDir: string;  private readonly todosDir: string;  private readonly contentPresenceCache = new Map<    string,    { mtimeMs: number; size: number; hasContent: boolean }  >();  private readonly sessionMetadataCache = new Map<    string,    {      mtimeMs: number;      size: number;      metadata: Awaited<ReturnType<typeof analyzeSessionFileMetadata>>;    }  >();  private readonly sessionPreviewCache = new Map<    string,    { mtimeMs: number; size: number; preview: { text: string; timestamp: string } | null }  >();   // Delegated services  private readonly fsProvider: FileSystemProvider;  private readonly sessionContentFilter: typeof SessionContentFilter;  private readonly worktreeGrouper: WorktreeGrouper;  private readonly subagentLocator: SubagentLocator;  private readonly sessionSearcher: SessionSearcher;  private readonly projectPathResolver: ProjectPathResolver;   constructor(projectsDir?: string, todosDir?: string, fsProvider?: FileSystemProvider) {    this.projectsDir = projectsDir ?? getProjectsBasePath();    this.todosDir = todosDir ?? getTodosBasePath();    this.fsProvider = fsProvider ?? new LocalFileSystemProvider();     // Initialize delegated services    this.sessionContentFilter = SessionContentFilter;    this.worktreeGrouper = new WorktreeGrouper(this.projectsDir, this.fsProvider);    this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider);    this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider);    this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider);  }   // ===========================================================================  // Project Scanning  // ===========================================================================   /**   * Scans the projects directory and returns a list of all projects.   * @returns Promise resolving to projects sorted by most recent activity   */  async scan(): Promise<Project[]> {    const startedAt = Date.now();    try {      if (!(await this.fsProvider.exists(this.projectsDir))) {        logger.warn(`Projects directory does not exist: ${this.projectsDir}`);        return [];      }       // Clear the subproject registry on full re-scan      subprojectRegistry.clear();       const entries = await this.fsProvider.readdir(this.projectsDir);       // Filter to only directories with valid encoding pattern      const projectDirs = entries.filter(        (entry) => entry.isDirectory() && isValidEncodedPath(entry.name)      );       // Process each project directory (may return multiple projects per dir)      const projectArrays = await this.collectFulfilledInBatches(        projectDirs,        this.fsProvider.type === 'ssh' ? 8 : 24,        async (dir) => this.scanProject(dir.name)      );       // Flatten and sort by most recent      const validProjects = projectArrays.flat();      validProjects.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));       if (this.fsProvider.type === 'ssh') {        logger.debug(          `SSH scan completed: ${validProjects.length} projects in ${Date.now() - startedAt}ms`        );      }       return validProjects;    } catch (error) {      logger.error('Error scanning projects directory:', error);      return [];    }  }   // ===========================================================================  // Repository Grouping (Worktree Support)  // ===========================================================================   /**   * Scans projects and groups them by git repository.   * Projects belonging to the same git repository (main repo + worktrees)   * are grouped together under a single RepositoryGroup.   * Non-git projects are represented as single-worktree groups.   *   * Sessions are filtered to exclude noise-only sessions, so counts   * accurately reflect visible sessions in the UI.   *   * @returns Promise resolving to RepositoryGroups sorted by most recent activity   */  async scanWithWorktreeGrouping(): Promise<RepositoryGroup[]> {    try {      // 1. Scan all projects using existing logic      const projects = await this.scan();       if (projects.length === 0) {        return [];      }       // 2. Delegate to WorktreeGrouper      return this.worktreeGrouper.groupByRepository(projects);    } catch (error) {      logger.error('Error scanning with worktree grouping:', error);      return [];    }  }   /**   * Lists sessions for a specific worktree within a repository group.   * This is a convenience method that delegates to listSessions since   * worktree.id is the same as project.id.   *   * @param worktreeId - The worktree ID (same as project ID)   */  async listWorktreeSessions(worktreeId: string): Promise<Session[]> {    return this.listSessions(worktreeId);  }   // ===========================================================================  // Project Scanning (continued)  // ===========================================================================   /**   * Scans a single project directory and returns project metadata.   * If sessions have different cwd values, splits into multiple projects.   */  private async scanProject(encodedName: string): Promise<Project[]> {    try {      const projectPath = path.join(this.projectsDir, encodedName);      const entries = await this.fsProvider.readdir(projectPath);       // Get session files (.jsonl at root level)      const sessionFiles = entries.filter(        (entry) => entry.isFile() && entry.name.endsWith('.jsonl')      );       if (sessionFiles.length === 0) {        return [];      }       // Collect file stats and cwd for each session      interface SessionInfo {        sessionId: string;        filePath: string;        mtimeMs: number;        birthtimeMs: number;        cwd: string | null;      }       const shouldSplitByCwd = this.fsProvider.type !== 'ssh';      const sessionInfos = await this.collectFulfilledInBatches(        sessionFiles,        this.fsProvider.type === 'ssh' ? 32 : 128,        async (file) => {          const filePath = path.join(projectPath, file.name);          const { mtimeMs, birthtimeMs } = await this.resolveFileDetails(file, filePath);          let cwd: string | null = null;           // Over SSH, avoid reading every file body during project discovery.          if (shouldSplitByCwd) {            try {              cwd = await extractCwd(filePath, this.fsProvider);            } catch {              // Ignore unreadable files            }          }           return {            sessionId: extractSessionId(file.name),            filePath,            mtimeMs,            birthtimeMs,            cwd,          } satisfies SessionInfo;        }      );       if (sessionInfos.length === 0) {        return [];      }       // Group sessions by cwd      const cwdGroups = new Map<string, SessionInfo[]>();      const baseName = extractProjectName(encodedName);      const decodedFallback = baseName; // Used when cwd is null       for (const info of sessionInfos) {        const key = shouldSplitByCwd ? (info.cwd ?? `__decoded__${decodedFallback}`) : encodedName;        const group = cwdGroups.get(key) ?? [];        group.push(info);        cwdGroups.set(key, group);      }       // If only 1 unique cwd, return single project (current behavior)      if (cwdGroups.size <= 1) {        const allSessionIds = sessionInfos.map((s) => s.sessionId);        let mostRecentSession: number | undefined;        let createdAt = Date.now();        for (const info of sessionInfos) {          if (!mostRecentSession || info.mtimeMs > mostRecentSession) {            mostRecentSession = info.mtimeMs;          }          if (info.birthtimeMs < createdAt) {            createdAt = info.birthtimeMs;          }        }         const sessionPaths = sessionInfos.map((s) => s.filePath);        const actualPath = await this.projectPathResolver.resolveProjectPath(encodedName, {          sessionPaths,        });         return [          {            id: encodedName,            path: actualPath,            name: baseName,            sessions: allSessionIds,            createdAt: Math.floor(createdAt),            mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,          },        ];      }       // Multiple unique cwds: split into subprojects      const projects: Project[] = [];       // Find the "root" cwd (shortest path, or the one matching the decoded name)      const cwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__'));      const rootCwd = cwdKeys.reduce(        (shortest, cwd) => (cwd.length <= shortest.length ? cwd : shortest),        cwdKeys[0] ?? ''      );       for (const [cwdKey, sessions] of cwdGroups) {        const isDecodedFallback = cwdKey.startsWith('__decoded__');        const actualCwd = isDecodedFallback ? null : cwdKey;         // Register in subproject registry        const sessionIds = sessions.map((s) => s.sessionId);        const compositeId = subprojectRegistry.register(          encodedName,          actualCwd ?? decodedFallback,          sessionIds        );         // Compute timestamps        let mostRecentSession: number | undefined;        let createdAt = Date.now();        for (const info of sessions) {          if (!mostRecentSession || info.mtimeMs > mostRecentSession) {            mostRecentSession = info.mtimeMs;          }          if (info.birthtimeMs < createdAt) {            createdAt = info.birthtimeMs;          }        }         // Build display name        let displayName: string;        if (!actualCwd || actualCwd === rootCwd) {          displayName = baseName;        } else {          // Use last segment of cwd for disambiguation          const lastSegment = path.basename(actualCwd);          displayName = `${baseName} (${lastSegment})`;        }         projects.push({          id: compositeId,          path: actualCwd ?? decodedFallback,          name: displayName,          sessions: sessionIds,          createdAt: Math.floor(createdAt),          mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,        });      }       return projects;    } catch (error) {      logger.error(`Error scanning project ${encodedName}:`, error);      return [];    }  }   /**   * Gets details for a specific project by ID.   * Handles composite IDs by scanning the base directory and finding the matching subproject.   */  async getProject(projectId: string): Promise<Project | null> {    const baseDir = extractBaseDir(projectId);    const projectPath = path.join(this.projectsDir, baseDir);     if (!(await this.fsProvider.exists(projectPath))) {      return null;    }     // For composite IDs, scan and find the matching subproject    if (subprojectRegistry.isComposite(projectId)) {      const projects = await this.scanProject(baseDir);      return projects.find((p) => p.id === projectId) ?? null;    }     const projects = await this.scanProject(baseDir);    return projects.find((p) => p.id === projectId) ?? projects[0] ?? null;  }   // ===========================================================================  // Session Listing  // ===========================================================================   /**   * Lists all sessions for a given project with metadata.   * Filters out sessions that contain only noise messages.   */  async listSessions(projectId: string): Promise<Session[]> {    try {      const baseDir = extractBaseDir(projectId);      const projectPath = path.join(this.projectsDir, baseDir);      const sessionFilter = await this.getSessionFilterForProject(projectId);      const shouldFilterNoise = this.fsProvider.type !== 'ssh';      const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep';       if (!(await this.fsProvider.exists(projectPath))) {        return [];      }       const entries = await this.fsProvider.readdir(projectPath);      let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));       // Filter to only sessions belonging to this subproject      if (sessionFilter) {        sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name)));      }       const sessionPaths = sessionFiles.map((file) => path.join(projectPath, file.name));      const decodedPath = await this.resolveProjectPathForId(projectId, sessionPaths);       const sessions = await Promise.all(        sessionFiles.map(async (file) => {          const sessionId = extractSessionId(file.name);          const filePath = path.join(projectPath, file.name);          const fileDetails = await this.resolveFileDetails(file, filePath);          const prefetchedMtimeMs = fileDetails.mtimeMs;          const prefetchedSize = fileDetails.size;          const prefetchedBirthtimeMs = fileDetails.birthtimeMs;           if (shouldFilterNoise) {            // Check if session has non-noise messages (delegated to SessionContentFilter)            const hasContent = await this.hasDisplayableContent(              filePath,              prefetchedMtimeMs,              prefetchedSize            );            if (!hasContent) {              return null; // Filter out noise-only sessions            }          }           return this.buildSessionForListing(            metadataLevel,            projectId,            sessionId,            filePath,            decodedPath,            prefetchedMtimeMs,            prefetchedSize,            prefetchedBirthtimeMs          );        })      );       // Filter out null results (noise-only sessions)      const validSessions = sessions.filter((s): s is Session => s !== null);       // Sort by created date (most recent first)      validSessions.sort((a, b) => b.createdAt - a.createdAt);       return validSessions;    } catch (error) {      logger.error(`Error listing sessions for project ${projectId}:`, error);      return [];    }  }   /**   * Lists sessions for a project with cursor-based pagination.   * Efficiently fetches only the sessions needed for the current page.   *   * @param projectId - The project ID to list sessions for   * @param cursor - Base64-encoded cursor from previous page (null for first page)   * @param limit - Number of sessions to return (default 20)   * @returns Paginated result with sessions, cursor, and metadata   */  async listSessionsPaginated(    projectId: string,    cursor: string | null,    limit: number = 20,    options?: SessionsPaginationOptions  ): Promise<PaginatedSessionsResult> {    const startedAt = Date.now();    try {      const includeTotalCount = options?.includeTotalCount ?? false;      const prefilterAll = options?.prefilterAll ?? false;      const baseDir = extractBaseDir(projectId);      const projectPath = path.join(this.projectsDir, baseDir);      const sessionFilter = await this.getSessionFilterForProject(projectId);      const shouldFilterNoise = this.fsProvider.type !== 'ssh';      const metadataLevel: SessionMetadataLevel =        options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep');       if (!(await this.fsProvider.exists(projectPath))) {        return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };      }       // Step 1: Get all session files with their timestamps (lightweight stat calls)      const entries = await this.fsProvider.readdir(projectPath);      let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));       // Filter to only sessions belonging to this subproject      if (sessionFilter) {        sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name)));      } 

Lines 1–500 of 1241.