238 lines
9.9 KiB
TypeScript
238 lines
9.9 KiB
TypeScript
import React from 'react';
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
|
import type { WorktreeMetadata } from '@/types/worktree';
|
|
import type { SessionGroup, SessionNode } from '../types';
|
|
import {
|
|
compareSessionsByPinnedAndTime,
|
|
dedupeSessionsById,
|
|
getArchivedScopeKey,
|
|
normalizeForBranchComparison,
|
|
normalizePath,
|
|
} from '../utils';
|
|
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
|
|
|
type Args = {
|
|
homeDirectory: string | null;
|
|
worktreeMetadata: Map<string, WorktreeMetadata>;
|
|
pinnedSessionIds: Set<string>;
|
|
gitDirectories: Map<string, { status?: { current?: string | null } | null }>;
|
|
};
|
|
|
|
export const useSessionGrouping = (args: Args) => {
|
|
const buildGroupSearchText = React.useCallback((group: SessionGroup): string => {
|
|
return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase();
|
|
}, []);
|
|
|
|
const buildSessionSearchText = React.useCallback((session: Session): string => {
|
|
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
|
|
const sessionTitle = (session.title || 'Untitled Session').trim();
|
|
return `${sessionTitle} ${sessionDirectory}`.toLowerCase();
|
|
}, []);
|
|
|
|
const filterSessionNodesForSearch = React.useCallback(
|
|
(nodes: SessionNode[], query: string): SessionNode[] => {
|
|
if (!query) {
|
|
return nodes;
|
|
}
|
|
|
|
return nodes.flatMap((node) => {
|
|
const nodeMatches = buildSessionSearchText(node.session).includes(query);
|
|
if (nodeMatches) {
|
|
return [node];
|
|
}
|
|
|
|
const filteredChildren = filterSessionNodesForSearch(node.children, query);
|
|
if (filteredChildren.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return [{ ...node, children: filteredChildren }];
|
|
});
|
|
},
|
|
[buildSessionSearchText],
|
|
);
|
|
|
|
const buildGroupedSessions = React.useCallback(
|
|
(
|
|
projectSessions: Session[],
|
|
projectRoot: string | null,
|
|
availableWorktrees: WorktreeMetadata[],
|
|
projectRootBranch: string | null,
|
|
projectIsRepo: boolean,
|
|
) => {
|
|
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
|
|
const sortedProjectSessions = dedupeSessionsById(projectSessions)
|
|
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, args.pinnedSessionIds));
|
|
|
|
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
|
|
const childrenMap = new Map<string, Session[]>();
|
|
sortedProjectSessions.forEach((session) => {
|
|
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
|
if (!parentID) return;
|
|
const collection = childrenMap.get(parentID) ?? [];
|
|
collection.push(session);
|
|
childrenMap.set(parentID, collection);
|
|
});
|
|
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, args.pinnedSessionIds)));
|
|
|
|
const worktreeByPath = new Map<string, WorktreeMetadata>();
|
|
availableWorktrees.forEach((meta) => {
|
|
if (meta.path) {
|
|
const normalized = normalizePath(meta.path) ?? meta.path;
|
|
worktreeByPath.set(normalized, meta);
|
|
}
|
|
});
|
|
|
|
const getSessionWorktree = (session: Session): WorktreeMetadata | null => {
|
|
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
|
const sessionWorktreeMeta = args.worktreeMetadata.get(session.id) ?? null;
|
|
if (sessionWorktreeMeta) return sessionWorktreeMeta;
|
|
if (sessionDirectory) {
|
|
const worktree = worktreeByPath.get(sessionDirectory) ?? null;
|
|
if (worktree && sessionDirectory !== normalizedProjectRoot) {
|
|
return worktree;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const buildProjectNode = (session: Session): SessionNode => {
|
|
const children = childrenMap.get(session.id) ?? [];
|
|
return { session, children: children.map((child) => buildProjectNode(child)), worktree: getSessionWorktree(session) };
|
|
};
|
|
|
|
const roots = sortedProjectSessions.filter((session) => {
|
|
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
|
if (!parentID) return true;
|
|
return !sessionMap.has(parentID);
|
|
});
|
|
|
|
const groupedNodes = new Map<string, SessionNode[]>();
|
|
const archivedKey = '__archived__';
|
|
|
|
const getGroupKey = (session: Session) => {
|
|
if (session.time?.archived) return archivedKey;
|
|
const metadataPath = normalizePath(args.worktreeMetadata.get(session.id)?.path ?? null);
|
|
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
|
if (!metadataPath && !sessionDirectory) return archivedKey;
|
|
const fallbackDirectory = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
|
const normalizedDir = metadataPath ?? sessionDirectory ?? fallbackDirectory;
|
|
if (!normalizedDir) return archivedKey;
|
|
if (normalizedDir !== normalizedProjectRoot && worktreeByPath.has(normalizedDir)) return normalizedDir;
|
|
if (normalizedDir === normalizedProjectRoot) return normalizedProjectRoot ?? '__project_root__';
|
|
return archivedKey;
|
|
};
|
|
|
|
roots.forEach((session) => {
|
|
const node = buildProjectNode(session);
|
|
const groupKey = getGroupKey(session);
|
|
if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []);
|
|
groupedNodes.get(groupKey)?.push(node);
|
|
});
|
|
|
|
const rootKey = normalizedProjectRoot ?? '__project_root__';
|
|
const groups: SessionGroup[] = [{
|
|
id: 'root',
|
|
label: (projectIsRepo && projectRootBranch && projectRootBranch !== 'HEAD') ? `project root: ${projectRootBranch}` : 'project root',
|
|
branch: projectRootBranch ?? null,
|
|
description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, args.homeDirectory) : null,
|
|
isMain: true,
|
|
isArchivedBucket: false,
|
|
worktree: null,
|
|
directory: normalizedProjectRoot,
|
|
folderScopeKey: normalizedProjectRoot,
|
|
sessions: groupedNodes.get(rootKey) ?? [],
|
|
}];
|
|
|
|
// Calculate activity info for each worktree to determine sorting priority
|
|
const worktreeActivityInfo = new Map<string, { hasActiveSession: boolean; lastUpdatedAt: number }>();
|
|
availableWorktrees.forEach((meta) => {
|
|
const directory = normalizePath(meta.path) ?? meta.path;
|
|
const sessionsInWorktree = groupedNodes.get(directory) ?? [];
|
|
const hasActiveSession = sessionsInWorktree.length > 0;
|
|
// Calculate the latest update time among all sessions in this worktree
|
|
const lastUpdatedAt = sessionsInWorktree.reduce((max, node) => {
|
|
const updatedAt = Number(node.session.time?.updated ?? node.session.time?.created ?? 0);
|
|
if (!Number.isFinite(updatedAt)) {
|
|
return max;
|
|
}
|
|
return Math.max(max, updatedAt);
|
|
}, 0);
|
|
|
|
worktreeActivityInfo.set(directory, { hasActiveSession, lastUpdatedAt });
|
|
});
|
|
|
|
// Sort worktrees: active first (by last updated desc), then inactive (by label asc)
|
|
const sortedWorktrees = [...availableWorktrees].sort((a, b) => {
|
|
const aDir = normalizePath(a.path) ?? a.path;
|
|
const bDir = normalizePath(b.path) ?? b.path;
|
|
const aInfo = worktreeActivityInfo.get(aDir) ?? { hasActiveSession: false, lastUpdatedAt: 0 };
|
|
const bInfo = worktreeActivityInfo.get(bDir) ?? { hasActiveSession: false, lastUpdatedAt: 0 };
|
|
|
|
// First priority: active status (active first)
|
|
if (aInfo.hasActiveSession !== bInfo.hasActiveSession) {
|
|
return aInfo.hasActiveSession ? -1 : 1;
|
|
}
|
|
|
|
// Second priority: for active worktrees, sort by last updated (desc)
|
|
if (aInfo.hasActiveSession && bInfo.hasActiveSession) {
|
|
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
|
|
}
|
|
|
|
// Third priority: for inactive worktrees, sort by label (asc)
|
|
const aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase();
|
|
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
|
|
return aLabel.localeCompare(bLabel);
|
|
});
|
|
|
|
sortedWorktrees.forEach((meta) => {
|
|
const directory = normalizePath(meta.path) ?? meta.path;
|
|
const currentBranch = args.gitDirectories.get(directory)?.status?.current?.trim() || null;
|
|
const metadataBranch = meta.branch?.trim() || null;
|
|
const shouldSyncLabelWithBranch = Boolean(
|
|
currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch),
|
|
);
|
|
const label = shouldSyncLabelWithBranch
|
|
? currentBranch!
|
|
: (meta.label || meta.name || formatDirectoryName(directory, args.homeDirectory) || directory);
|
|
|
|
groups.push({
|
|
id: `worktree:${directory}`,
|
|
label,
|
|
branch: currentBranch || metadataBranch,
|
|
description: formatPathForDisplay(directory, args.homeDirectory),
|
|
isMain: false,
|
|
isArchivedBucket: false,
|
|
worktree: meta,
|
|
directory,
|
|
folderScopeKey: directory,
|
|
sessions: groupedNodes.get(directory) ?? [],
|
|
});
|
|
});
|
|
|
|
groups.push({
|
|
id: 'archived',
|
|
label: 'archived',
|
|
branch: null,
|
|
description: 'Archived and unassigned sessions',
|
|
isMain: false,
|
|
isArchivedBucket: true,
|
|
worktree: null,
|
|
directory: null,
|
|
folderScopeKey: normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null,
|
|
sessions: groupedNodes.get(archivedKey) ?? [],
|
|
});
|
|
|
|
return groups;
|
|
},
|
|
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitDirectories],
|
|
);
|
|
|
|
return {
|
|
buildGroupSearchText,
|
|
buildSessionSearchText,
|
|
filterSessionNodesForSearch,
|
|
buildGroupedSessions,
|
|
};
|
|
};
|