165 lines
7.0 KiB
TypeScript
165 lines
7.0 KiB
TypeScript
import React from 'react';
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
|
|
|
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
|
import { isBtwSession } from '@/lib/sessionBtwMetadata';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
|
import { useGitAllBranches } from '@/stores/useGitStore';
|
|
import type { SessionNode } from '../types';
|
|
import { isPathWithinProject } from '../utils';
|
|
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
|
|
|
export type SwitcherItem = {
|
|
node: SessionNode;
|
|
projectId: string | null;
|
|
groupDirectory: string | null;
|
|
secondaryMeta: {
|
|
projectLabel?: string | null;
|
|
branchLabel?: string | null;
|
|
} | null;
|
|
};
|
|
|
|
const MAX_PARENT_SESSIONS = 7;
|
|
|
|
type SwitcherItemsOptions = {
|
|
scopeProjectId?: string | null;
|
|
/** How many parent sessions to return (default 7 — the desktop dropdown). */
|
|
maxParents?: number;
|
|
};
|
|
|
|
const normalize = (value: string | null | undefined): string | null => {
|
|
if (!value) return null;
|
|
const replaced = value.replace(/\\/g, '/');
|
|
if (replaced === '/') return '/';
|
|
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
|
|
};
|
|
|
|
const formatProjectLabel = (project: { label?: string | null; path: string } | null): string | null => {
|
|
if (!project) return null;
|
|
const trimmed = project.label?.trim();
|
|
if (trimmed) return trimmed;
|
|
const segments = project.path.split(/[\\/]/).filter(Boolean);
|
|
return segments[segments.length - 1] ?? null;
|
|
};
|
|
|
|
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
|
|
const { scopeProjectId = null, maxParents = MAX_PARENT_SESSIONS } = options;
|
|
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
|
const projects = useProjectsStore((state) => state.projects);
|
|
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
|
const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById);
|
|
const branchesByDirectory = useGitAllBranches();
|
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
|
|
|
// Worktree sessions live OUTSIDE their project's path, so prefix matching
|
|
// can't resolve their project — and their branch is known from worktree
|
|
// discovery long before any git status is fetched for that directory.
|
|
const worktreeInfoByPath = React.useMemo(() => {
|
|
const map = new Map<string, { projectPath: string; branch: string | null }>();
|
|
for (const [projectPath, worktrees] of availableWorktreesByProject) {
|
|
const normalizedProjectPath = normalize(projectPath);
|
|
if (!normalizedProjectPath) continue;
|
|
for (const worktree of worktrees) {
|
|
const worktreePath = normalize(worktree.path);
|
|
if (!worktreePath) continue;
|
|
map.set(worktreePath, { projectPath: normalizedProjectPath, branch: worktree.branch?.trim() || null });
|
|
}
|
|
}
|
|
return map;
|
|
}, [availableWorktreesByProject]);
|
|
|
|
const normalizedProjects = React.useMemo(
|
|
() => projects
|
|
.map((project) => ({ ...project, normalizedPath: normalize(project.path) }))
|
|
.filter((project) => project.normalizedPath),
|
|
[projects],
|
|
);
|
|
|
|
const findProjectForDirectory = React.useCallback(
|
|
(directory: string | null) => {
|
|
if (!directory) return null;
|
|
// Known worktree → its project, regardless of where the worktree lives.
|
|
const worktreeInfo = worktreeInfoByPath.get(normalize(directory) ?? directory);
|
|
if (worktreeInfo) {
|
|
const byPath = normalizedProjects.find((project) => project.normalizedPath === worktreeInfo.projectPath);
|
|
if (byPath) return byPath;
|
|
}
|
|
const matches = normalizedProjects
|
|
.filter((project) => isPathWithinProject(directory, project.normalizedPath))
|
|
.sort((a, b) => (b.normalizedPath?.length ?? 0) - (a.normalizedPath?.length ?? 0));
|
|
return matches[0] ?? null;
|
|
},
|
|
[normalizedProjects, worktreeInfoByPath],
|
|
);
|
|
|
|
const items = React.useMemo<SwitcherItem[]>(() => {
|
|
if (!enabled) return [];
|
|
|
|
const childrenByParent = new Map<string, Session[]>();
|
|
for (const session of activeSessions) {
|
|
const parentId = (session as Session & { parentID?: string | null }).parentID;
|
|
if (!parentId) continue;
|
|
if (session.time?.archived) continue;
|
|
const bucket = childrenByParent.get(parentId);
|
|
if (bucket) {
|
|
bucket.push(session);
|
|
} else {
|
|
childrenByParent.set(parentId, [session]);
|
|
}
|
|
}
|
|
childrenByParent.forEach((list) => {
|
|
list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
|
|
});
|
|
|
|
const parents = activeSessions
|
|
.filter((session) => !session.time?.archived)
|
|
// btw forks stay hidden until promoted to a full session
|
|
.filter((session) => !isBtwSession(session))
|
|
.filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session)))
|
|
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
|
|
.filter((session) => {
|
|
if (!scopeProjectId) return true;
|
|
const directory = resolveGlobalSessionDirectory(session);
|
|
return findProjectForDirectory(directory)?.id === scopeProjectId;
|
|
})
|
|
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks))
|
|
.slice(0, maxParents);
|
|
|
|
const buildNode = (session: Session): SessionNode => {
|
|
const childSessions = childrenByParent.get(session.id) ?? [];
|
|
return {
|
|
session,
|
|
children: childSessions.map((child) => buildNode(child)),
|
|
worktree: null,
|
|
};
|
|
};
|
|
|
|
return parents.map((session) => {
|
|
const directory = resolveGlobalSessionDirectory(session);
|
|
const matchedProject = findProjectForDirectory(directory);
|
|
const projectLabel = formatProjectLabel(matchedProject);
|
|
// Live git branch when available; the discovered worktree branch fills
|
|
// in for directories whose git status hasn't been fetched yet.
|
|
const worktreeInfo = directory ? worktreeInfoByPath.get(normalize(directory) ?? directory) : null;
|
|
const liveBranch = directory ? branchesByDirectory.get(directory) : undefined;
|
|
const branchLabel = liveBranch ?? worktreeInfo?.branch ?? null;
|
|
return {
|
|
node: buildNode(session),
|
|
projectId: matchedProject?.id ?? null,
|
|
groupDirectory: directory,
|
|
secondaryMeta: {
|
|
projectLabel,
|
|
branchLabel: branchLabel && branchLabel !== projectLabel ? branchLabel : null,
|
|
},
|
|
};
|
|
});
|
|
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
|
|
|
|
return items;
|
|
};
|